'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 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; 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) { 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 caps = { 'com.daplie.tel.twilio': function (/*opts*/) { if (_twilio) { return _twilio; } var Twilio = require('twilio'); _twilio = new Twilio.RestClient(siteConfig['twilio.com'].id, siteConfig['twilio.com'].auth); return apiDeps.Promise.resolve(_twilio); } }; req.getSiteCapability = function (capname, opts) { if (caps[capname]) { return caps[capname](opts); } 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 () { // 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(/\/$/, ''); // 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 + '/' + 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"); }); }; };