walnut.js/lib/apis.js

388 lines
14 KiB
JavaScript

'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: {} };
// 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) {
console.log('sanity', 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) {
console.log(api, pkgId, 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]) {
return modelsCache[clientUrih];
}
// 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
modelsCache[clientUrih] = apiFactories.systemSqlFactory.create({
init: true
, dbname: clientUrih // '#' is a valid file name character
}).then(function (db) {
var wrap = require('masterquest-sqlite3');
return wrap.wrap(db, dir).then(function (models) {
modelsCache[clientUrih] = PromiseA.resolve(models);
return models;
});
});
return modelsCache[clientUrih];
}
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 = require('./lib/common').promisableRequest;
myApp.handleRejection = require('./lib/common').rejectableRequest;
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*/);
myApp.use('/public', function preHandler(req, res, next) {
// TODO authenticate or use guest user
next();
});
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 getSiteMailerProp(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
req.Stripe = require('stripe')(siteConfig['stripe.com'].live.secret);
req.StripeTest = require('stripe')(siteConfig['stripe.com'].test.secret);
req._walnutOriginalUrl = req.url;
// "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello'
req.url = req.url.replace(/\/api\//, '').replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/');
console.log('[prehandler] req.url', req.url);
next();
});
});
//
// 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;
console.log('[posthandler] req.url', req.url);
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 () {
console.log('[sanity check]', req.url);
// 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(/#$/, '');
// 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, 'experienceId', {
enumerable: true
, configurable: false
, writable: false
, value: clientUrih
});
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)
return 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);
}
});
}
});
});
};
};