walnut.js/lib/main.js

331 lines
11 KiB
JavaScript

'use strict';
module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi) {
var PromiseA = require('bluebird');
var path = require('path');
var fs = PromiseA.promisifyAll(require('fs'));
// NOTE: each process has its own cache
var localCache = { le: {}, statics: {} };
var express = require('express');
var setupDomain = xconfx.setupDomain = ('cloud.' + xconfx.primaryDomain);
var apiApp;
var setupApp;
var CORS;
var cors;
function redirectSetup(reason, req, res) {
console.log('xconfx', xconfx);
var url = 'https://cloud.' + xconfx.primaryDomain;
if (443 !== xconfx.externalPort) {
url += ':' + xconfx.externalPort;
}
url += '#referrer=' + reason;
res.statusCode = 302;
res.setHeader('Location', url);
res.end("The static pages for '" + reason + "' are not listed in '" + path.join(xconfx.sitespath, reason) + "'");
}
function disallowSymLinks(req, res) {
res.end(
"Symbolic Links are not supported on all platforms and are therefore disallowed."
+ " Instead, simply create a file of the same name as the link with a single line of text"
+ " which should be the relative or absolute path to the target directory."
);
}
function disallowNonFiles(req, res) {
res.end(
"Pipes, Blocks, Sockets, FIFOs, and other such nonsense are not permitted."
+ " Instead please create a directory from which to read or create a file "
+ " with a single line of text which should be the target directory to read from."
);
}
function securityError(req, res) {
res.end("Security Error: Link points outside of packages/pages");
}
function notConfigured(req, res, next) {
if (setupDomain !== req.hostname) {
console.log('[notConfigured] req.hostname', req.hostname);
if (/\.html\b/.test(req.url)) {
redirectSetup(req.hostname, req, res);
return;
}
}
if (!setupApp) {
//setupApp = express.static(path.join(xconfx.staticpath, 'com.daplie.walnut'));
setupApp = express.static(path.join('lib', 'com.daplie.walnut'));
}
setupApp(req, res, function () {
if ('/' === req.url) {
res.end('Sanity Fail: Configurator not found');
return;
}
next();
});
}
function loadSiteHandler(name) {
return function handler(req, res, next) {
// path.join('packages/pages', 'com.daplie.hello') // package name (used as file-link)
// path.join('packages/pages', 'domain.tld#hello') // dynamic exact url match
var sitepath = path.join(xconfx.sitespath, name);
console.log('sitepath', sitepath);
return fs.lstatAsync(sitepath).then(function (stat) {
if (stat.isSymbolicLink()) {
return disallowSymLinks;
}
if (stat.isDirectory()) {
return express.static(sitepath);
}
if (!stat.isFile()) {
return disallowNonFiles;
}
// path.join('packages/pages', 'domain.tld#hello') // a file (not folder) which contains a list of roots
// may look like this:
//
// com.daplie.hello
// tld.domain.app
//
// this is basically a 'recursive mount' to signify that 'com.daplie.hello' should be tried first
// and if no file matches that 'tld.domain.app' may be tried next, and so on
//
// this may well become a .htaccess type of situation allowing for redirects and such
return fs.readFileAsync(sitepath, 'utf8').then(function (text) {
// TODO allow cascading multiple lines
text = text.trim().split(/\n/)[0];
// TODO rerun the above, disallowing link-style (or count or memoize to prevent infinite loop)
// TODO make safe
var packagepath = path.resolve(xconfx.staticpath, text);
if (0 !== packagepath.indexOf(xconfx.staticpath)) {
return securityError;
}
// instead of actually creating new instances of express.static
// this same effect could be managed by internally re-writing the url (and restoring it)
return express.static(packagepath);
});
}, function (/*err*/) {
return notConfigured;
}).then(function (handler) {
// keep object reference intact
localCache.statics[name].handler = handler;
handler(req, res, next);
});
};
}
function staticHelper(appId, opts) {
console.log('[staticHelper]', appId);
// TODO inter-process cache expirey
// TODO add to xconfx.staticpath
xconfx.staticpath = path.join(__dirname, '..', '..', 'packages', 'pages');
xconfx.sitespath = path.join(__dirname, '..', '..', 'packages', 'sites');
// Reads in each of the sites directives as 'nodes'
return fs.readdirAsync(xconfx.sitespath).then(function (nodes) {
if (opts && opts.clear) {
localCache.statics = {};
}
// Order from longest (index length - 1) to shortest (index 0)
function shortToLong(a, b) {
return b.length - a.length;
}
nodes.sort(shortToLong);
nodes = nodes.filter(function (pkgName) {
console.log('[all apps]', pkgName);
// load the apps that match this id's domain and could match the path
// domain.daplie.me matches domain.daplie.me
// daplie.me#path#to#thing matches daplie.me
// daplie.me does NOT match daplie.me#path#to#thing
var reqParts = appId.split('#');
var pkgParts = pkgName.split('#');
var reqDomain = reqParts.shift();
var pkgDomain = pkgParts.shift();
var reqPath = reqParts.join('#');
var pkgPath = pkgParts.join('#');
if (reqPath.length) {
reqPath += '#';
}
if (pkgPath.length) {
pkgPath += '#';
}
if (!(reqDomain === pkgDomain && 0 === reqPath.indexOf(pkgPath))) {
return false;
}
if (!localCache.statics[pkgName]) {
console.log('[load this app]', pkgName);
localCache.statics[pkgName] = { handler: loadSiteHandler(pkgName), createdAt: Date.now() };
}
return true;
});
// Secure Matching
// apple.com#blah# apple.com#blah#
// apple.com.us# apple.com#foo#
// apple.com# apple.com#foo#
console.log('nodes', nodes);
nodes.some(function (pkgName) {
console.log('pkgName, appId', pkgName, appId);
if (0 === (appId + '#').indexOf(pkgName + '#')) {
if (appId !== pkgName) {
localCache.statics[appId] = localCache.statics[pkgName];
}
return true;
}
});
if (!localCache.statics[appId]) {
localCache.statics[appId] = { handler: notConfigured, createdAt: Date.now() };
}
localCache.staticsKeys = Object.keys(localCache.statics).sort(shortToLong);
return localCache.statics[appId];
});
}
function serveStaticHelper(appId, opts, req, res, next) {
var appIdParts = appId.split('#');
var appIdPart;
// TODO for <domain.tld>/<path>/apps/<package> the Uri should be <domain.tld>/<path>
res.setHeader('X-Walnut-Uri', appId.replace(/#/g, '/'));
// TODO configuration for allowing www
if (/^www\./.test(req.hostname)) {
// NOTE: acme responder and appcache unbricker must come before scrubTheDub
if (/\.(appcache|manifest)\b/.test(req.url)) {
require('./unbrick-appcache').unbrick(req, res);
return;
}
require('./no-www').scrubTheDub(req, res);
return;
}
/*
if (!redirectives && config.redirects) {
redirectives = require('./hostname-redirects').compile(config.redirects);
}
*/
/*
// TODO assets.example.com/sub/assets/com.example.xyz/
if (/^assets\./.test(req.hostname) && /\/assets(\/|$)/.test(req.url)) {
...
}
*/
// There may be some app folders named 'apple.com', 'apple.com#foo', and 'apple.com#foo#bar'
// Here we're sorting an appId broken into parts like [ 'apple.com', 'foo', 'bar' ]
// and wer're checking to see if this is perhaps '/' of 'apple.com/foo/bar' or '/foo/bar' of 'apple.com', etc
while (appIdParts.length) {
// TODO needs IPC to expire cache when an API says the app mounts have been updated
appIdPart = appIdParts.join('#');
if (localCache.statics[appIdPart]) {
break;
}
// TODO test via staticsKeys
appIdParts.pop();
}
if (!appIdPart || !localCache.statics[appIdPart]) {
console.log('[serveStaticHelper] appId', appId);
return staticHelper(appId).then(function (webapp) {
//localCache.statics[appId].handler(req, res, next);
webapp.handler(req, res, next);
});
}
console.log('[serveStaticHelper] appIdPart', appIdPart);
if (opts && opts.rewrite && -1 !== req.url.indexOf(appIdPart.replace(/#/g, '/').replace(/\/$/, ''))) {
req.url = req.url.slice(req.url.indexOf(appIdPart.replace(/#/g, '/').replace(/\/$/, '')) + appIdPart.replace(/(\/|#)$/, '').length);
if (0 !== req.url.indexOf('/')) {
req.url = '/' + req.url;
}
}
localCache.statics[appIdPart].handler(req, res, next);
if (Date.now() - localCache.statics[appIdPart].createdAt > (5 * 60 * 1000)) {
staticHelper(appId, { clear: true });
}
}
function serveStatic(req, res, next) {
// We convert the URL that was sent in the browser bar from
// 'https://domain.tld/foo/bar' to 'domain.tld#foo#bar'
var appId = req.hostname + req.url.replace(/\/+/g, '#').replace(/#$/, '');
serveStaticHelper(appId, null, req, res, next);
}
function serveApps(req, res, next) {
var appId = req.url.slice(1).replace(/\/+/g, '#').replace(/#$/, '');
if (/^apps\./.test(req.hostname)) {
appId = appId.replace(/^apps#/, '');
} else if (/\bapps#/.test(appId)) {
appId = appId.replace(/.*\bapps#/, '');
} else {
next();
return;
}
console.log('[serveApps] appId', appId);
serveStaticHelper(appId, { rewrite: true }, req, res, next);
}
// TODO set header 'X-ExperienceId: domain.tld/sub/path'
// This would let an app know whether its app is 'domain.tld' with a path of '/sub/path'
// or if its app is 'domain.tld/sub' with a path of '/path'
// TODO handle assets.example.com/sub/assets/com.example.xyz/
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) &&
next();
return;
}
// supports api.example.com/sub/app/api/com.example.xyz/
if (!apiApp) {
apiApp = require('./apis').create(xconfx, apiFactories, apiDeps);
}
if (/^OPTIONS$/i.test(req.method)) {
if (!cors) {
CORS = require('connect-cors');
cors = CORS({ credentials: true, headers: [
'X-Requested-With'
, 'X-HTTP-Method-Override'
, 'Content-Type'
, 'Accept'
, 'Authorization'
], methods: [ "GET", "POST", "PATCH", "PUT", "DELETE" ] });
}
cors(req, res, apiApp);
}
apiApp(req, res, next);
return;
});
app.use('/', errorIfApi);
app.use('/', serveStatic);
app.use('/', serveApps);
return PromiseA.resolve();
};