'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(__dirname, '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('[lib/main.js] 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 //apps/ the Uri should be / 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('/api', 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) && 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); return; } apiApp(req, res, next); return; }); app.use('/', errorIfApi); app.use('/', serveStatic); app.use('/', serveApps); return PromiseA.resolve(); };