UNPKG

cnn-routeomatic

Version:

A library for web server routing, redirecting and rewriting joy for ExpressJS.

809 lines (724 loc) 30.9 kB
'use strict'; const continents = require('../data/continents.json'), formatUrl = require('url').format, parseUrl = require('url').parse, regions = require('../data/regions.json'), TrieRoute = require('./trie-route'), utils = require('./utils'); /** * Use as a default handler for undefined routes * * @function * @param {object} req - The request object (RomRequest) * @param {object} _route - The route object * @param {object} _args - The arguments object */ function undefHandler(req, _route, _args) { // Nothing defined for this, so return an error 500 req.log.info(`Undefined route handler handling request for "${req.path}"`); req.error(500); } /** * RouteTable Object constructor * * @constructor * @memberof RouteTable * @param {object} src - Source data for routes. * @param {object} config - Config object. * @param {object} [config.defaults] - Defaults object, optional * @param {boolean} [config.defaults.allowUndefinedHandler] - Default allowUndefinedHandler value, uses false if not set. * @param {boolean} [config.defaults.allowWrite] - Default allowWrite value, uses false if not set. * @param {number} [config.defaults.redirectCode] - Default redirect code to use, uses 302 if not set. * @param {object} [config.env] - Environment object * @param {object} [config.env.conds] - Environment object containing route conditionals * @param {object} [config.env.subs] - Environment object containing substitutions * @param {object} [config.log] - Shared logger, if used. * @param {object} config.routeHandlers - Route handler functions namespace object */ function RouteTable(src, config) { let cnt = 0, conds = null, defHandler = null, doSubs = false, routeHandlers, subs = null; // Initialize values this.count = 0; this.desc = ''; this.forcePort = 0; this.forceProto = ''; this.isCaseSpecific = true; this.isRegexMatch = false; this.isTrieMatch = false; this.matchType = 'trie'; this.matchUsingQueryParams = false; this.resolver = null; this.routes = []; this.trie = null; // Validate route table source object if (typeof src !== 'object' || src === null || typeof src.id !== 'string' || src.id.length === 0 || !Array.isArray(src.routes) || src.routes.length === 0) { throw new Error('Invalid route configuration or route table source data!'); } this.id = src.id; // Validate config object if (typeof config !== 'object' || config === null || typeof config.routeHandlers !== 'object' || config.routeHandlers === null) { throw new Error('Invalid or empty route table config!'); } // Use the shared log, if set this.log = config.log || utils.baseLogger; // Figure out the route handlers namespace if (typeof src.routeNamespace === 'string' && src.routeNamespace.length !== 0) { if (typeof config.routeHandlers[src.routeNamespace] !== 'object' || config.routeHandlers[src.routeNamespace] === null) { throw new Error(`Invalid route namespace "${src.routeNamespace}"!`); } routeHandlers = config.routeHandlers[src.routeNamespace]; } else { routeHandlers = config.routeHandlers; } this.undefinedHandler = (src.allowUndefinedHandler === true || (src.allowUndefinedHandler !== false && config.defaults.allowUndefinedHandler === true)) ? undefHandler : null; this.defaultAllowWrite = src.defaultAllowWrite || config.defaults.allowWrite || false; this.defaultRedirectCode = src.defaultRedirectCode || config.defaults.redirectCode || 302; if (typeof config.env === 'object' && config.env !== null) { conds = config.env.conds || null; subs = config.env.subs || null, doSubs = (subs !== null) ? true : false; } // Validate the match type (regex or trie) and set the resolver if (typeof src.matchType === 'string') { this.matchType = src.matchType.toLowerCase(); if (this.matchType === 'regex') { this.resolver = this.checkRegexRoutes.bind(this); this.isRegexMatch = true; } else if (this.matchType === 'trie' || this.matchType === 'simple') { this.resolver = this.checkTrieRoutes.bind(this); this.isTrieMatch = true; this.trie = new TrieRoute(); } else { throw new Error(`Invalid match type (${src.matchType}) for route table.`); } } else { throw new Error('Missing match type (regex, trie) for route table.'); } // Set the description if (typeof src.desc === 'string') { this.desc = src.desc; } // Set the case-specific options if (typeof src.isCaseSpecific === 'boolean') { this.isCaseSpecific = src.isCaseSpecific; } // Set the matching using query params flag if (typeof src.matchUsingQueryParams === 'boolean') { this.matchUsingQueryParams = src.matchUsingQueryParams; } // Are we forcing use of HTTP or HTTPS? if (typeof src.forceProto === 'string' && src.forceProto.length !== 0) { this.forceProto = src.forceProto.toLowerCase(); if (this.forceProto !== 'https' && this.forceProto !== 'http') { throw new Error(`Bad default forced protocol (${src.forceProto}) in route table.`); } } if (typeof src.forcePort === 'number' && src.forcePort >= 0) { this.forcePort = src.forcePort; } // Set the default handler, if any. If not set, check for "routeHandlers.default" and use that. if (typeof src.defaultHandler === 'string' && src.defaultHandler.length !== 0) { if (typeof routeHandlers[src.defaultHandler] === 'function') { defHandler = routeHandlers[src.defaultHandler]; } else { throw new Error(`Bad default handler "${src.defaultHandler}" specified for route table.`); } } else if (typeof routeHandlers.default === 'function') { defHandler = routeHandlers.default; } // Process the routes routeLoop: for (let r, i = 0; i < src.routes.length; i++) { r = src.routes[i]; // Check conditionals, if conds configured if (typeof r.conds === 'object' && r.conds !== null) { if (conds === null) { continue routeLoop; // Condition not set } for (let c in r.conds) { if (r.conds.hasOwnProperty(c)) { if (!conds.hasOwnProperty(c)) { continue routeLoop; // Matching condition not set } if (typeof r.conds[c] === 'string' && doSubs === true) { r.conds[c] = utils.substitute(r.conds[c], subs); } if (r.conds[c] !== conds[c]) { continue routeLoop; // Conditions do not match } } } } // Do substitutions, if subs configured if (doSubs === true) { r.on = utils.substitute(r.on, subs); } // Verify we have a route if (typeof r.on !== 'string' || r.on.length === 0) { throw new Error('Invalid or empty route match.'); } // Handle allowWrite if (typeof r.allowWrite !== 'boolean') { r.allowWrite = this.defaultAllowWrite; } // If method match set, verify it if (typeof r.methodMatch === 'string' && r.methodMatch.length !== 0) { if (!utils.isMethodValid(r.methodMatch)) { throw new Error(`Invalid method (${r.methodMatch}) specified for route runtime method match.`); } if (r.allowWrite !== true && utils.isWriteMethod(r.methodMatch)) { throw new Error(`Invalid method (${r.methodMatch}) specified for route runtime method match with allowWrite disabled.`); } } else { r.methodMatch = ''; } // If hostname match and subs set, do substitutions if (typeof r.hostMatch === 'string') { if (doSubs === true) { r.hostMatch = utils.substitute(r.hostMatch, subs); } if (r.hostMatch.length !== 0 && !utils.isHostnameValid(r.hostMatch)) { throw new Error(`Invalid hostname (${r.hostMatch}) specified for route runtime hostname match.`); } r.hostMatch = r.hostMatch.toLowerCase(); } else { r.hostMatch = ''; } // If protocol match set, verify it if (typeof r.protoMatch === 'string' && r.protoMatch.length !== 0) { if (r.protoMatch.search(/^(http|https)$/) === -1) { throw new Error(`Invalid protocol (${r.protoMatch}) specified for route runtime protocol match.`); } } else { r.protoMatch = ''; } // If port match set, verify it if (typeof r.portMatch === 'number') { if (r.portMatch < 0 || r.portMatch > 65535) { throw new Error(`Invalid port (${r.portMatch}) specified for route runtime port match.`); } } else { r.portMatch = 0; } // Force proto, if requested... if (typeof r.forceProto === 'string' && r.forceProto.length !== 0) { r.forceProto = r.forceProto.toLowerCase(); r.forcePort = this.forcePort; if (r.forceProto !== 'https' && r.forceProto !== 'http') { throw new Error(`Bad forced protocol (${r.forceProto}) specified for route.`); } } else { r.forceProto = this.forceProto; if (r.forceProto.length !== 0) { r.forcePort = this.forcePort; } } // Setup handlers and prep routes based on route type if (typeof r.rewrite === 'string') { // This is a rewrite if (doSubs === true) { r.rewrite = utils.substitute(r.rewrite, subs); if (typeof r.replace === 'string') { r.replace = utils.substitute(r.replace, subs); } } this.prepRewriteRoute(r); r.action = this.handleMatchedRewrite; } else if (typeof r.redirect === 'string') { // This is a redirect if (doSubs === true) { r.redirect = utils.substitute(r.redirect, subs); } this.prepRedirectRoute(r, (doSubs === true ? subs : null)); r.action = this.handleMatchedRedirect; } else { if (typeof r.do === 'string' && r.do.length !== 0) { // This is a handled route if (doSubs === true) { r.do = utils.substitute(r.do, subs); } if (typeof routeHandlers[r.do] === 'function') { r.action = routeHandlers[r.do]; } else if (this.undefinedHandler !== null) { // Well, the named handler isn't defined yet, but we're cool with that for now r.action = this.undefinedHandler; } else { throw new Error(`Invalid handler "${r.do}" for route #${i}: ${r.on}`); } } else if (defHandler !== null) { // This is a handled route using the default handler r.action = defHandler; } else { throw new Error(`Missing handler for route #${i}: ${r.on}`); } // Handle substitutions on known options if (doSubs === true && typeof r.options === 'object' && r.options !== null) { if (typeof r.options.headers === 'object' && r.options.headers !== null) { for (let ph in r.options.headers) { if (r.options.headers.hasOwnProperty(ph) && typeof r.options.headers[ph] === 'string') { r.options.headers[ph] = utils.substitute(r.options.headers[ph], subs); } } } if (typeof r.options.proxy === 'object' && r.options.proxy !== null) { this.processProxyOpts(r.options.proxy, subs); } if (typeof r.options.altProxy === 'object' && r.options.altProxy !== null) { this.processProxyOpts(r.options.altProxy, subs); } } } // Add new route to route list if (this.isRegexMatch === true) { try { r.regex = new RegExp(r.on, (this.isCaseSpecific === false ? 'i' : '')), this.routes[cnt] = r; } catch (reErr) { throw new Error(`Error while adding RegExp route #${i} (${r.on}) to the route list: ` + (reErr.message || 'Unknown')); } } else { // Trie match try { let em = r.on.lastIndexOf('#'), matchOn, normMatch = (this.isCaseSpecific === true) ? r.on : r.on.toLowerCase(); if (typeof r.postMatch === 'string' && r.postMatch.length !== 0) { // We have a postMatch, so compile the RegExp for it r.postMatchRE = new RegExp(r.postMatch); } if (em !== -1) { // There is an end marker (#), deal with it matchOn = normMatch.slice(0, em + 1); this.trie.add(matchOn, r); if (normMatch.length >= em) { // There is a control value after the marker if (normMatch.charAt(em + 1) === '?') { // #? means end match or add trailing slash without end marker matchOn = normMatch.slice(0, em) + '/'; this.trie.add(matchOn, r); } else if (normMatch.charAt(em + 1) === 's' && normMatch.charAt(em - 1) !== '/') { // #s means also match trailing slash with end marker matchOn = normMatch.slice(0, em) + '/#'; this.trie.add(matchOn, r); } else if (normMatch.charAt(em + 1) === 'i') { // #i means also match trailing slash and /index.html, each with end markers if (normMatch.charAt(em - 1) !== '/') { matchOn = normMatch.slice(0, em) + '/#'; this.trie.add(matchOn, r); } matchOn = normMatch.slice(0, em) + '/index.html#'; this.trie.add(matchOn, r); } } } else { this.trie.add(normMatch, r); } } catch (trErr) { throw new Error(`Error while adding route #${i} (${r.on}) to the Trie: ` + (trErr.message || 'Unknown')); } } cnt++; // Bump up the route counter } // Processed successfully this.count = cnt; } /** * Process proxy options * * @memberof RouteTable * @private * @param {object} opts - The options.proxy object * @param {object} subs - The substitutions object */ RouteTable.prototype.processProxyOpts = function (opts, subs) { if (typeof opts.hostname === 'string') { opts.hostname = utils.substitute(opts.hostname, subs); } if (typeof opts.path === 'string') { opts.path = utils.substitute(opts.path, subs); } if (typeof opts.pathMatch === 'string') { opts.pathMatch = utils.substitute(opts.pathMatch, subs); if (opts.pathMatch.search(/[\.\^\?\*\+\(\)\[\]\$\|\\]+/) !== -1) { // Probably a RegExp, so try treating it as one... try { let re = new RegExp(opts.pathMatch); // Yep, let's use that opts.pathMatch = re; } catch (rerr) { // Apparently not, leave it as a string } } } if (typeof opts.pathReplace === 'string') { opts.pathReplace = utils.substitute(opts.pathReplace, subs); } if (typeof opts.proto === 'string') { opts.proto = utils.substitute(opts.proto, subs); } if (typeof opts.auth === 'string') { opts.auth = utils.substitute(opts.auth, subs); } if (typeof opts.hash === 'string') { opts.hash = utils.substitute(opts.hash, subs); } if (typeof opts.query === 'string') { opts.query = utils.substitute(opts.query, subs); } if (typeof opts.headers === 'object' && opts.headers !== null) { for (let ph in opts.headers) { if (opts.headers.hasOwnProperty(ph) && typeof opts.headers[ph] === 'string') { opts.headers[ph] = utils.substitute(opts.headers[ph], subs); } } } }; /** * Prepare and validate a redirect route * * @memberof RouteTable * @private * @param {object} route - The route object * @param {object} subs - Use for substitutions if not null */ RouteTable.prototype.prepRedirectRoute = function (route, subs) { let urlObj = parseUrl(route.redirect); // Verify the redirect is to a valid URL if (!(urlObj.host || urlObj.pathname)) { throw new Error(`Invalid redirect rule for "${route.on}", bad redirect destination URL: ${route.redirect}`); } // Verify the geoTargeting if (typeof route.geoTarget === 'object' && route.geoTarget !== null) { let geoTarget = {}; for (let geo in route.geoTarget) { if (route.geoTarget.hasOwnProperty(geo)) { let newGeo = geo.toUpperCase(); // Replace static/domestic/international hostname in redirect, as appropriate geoTarget[newGeo] = route.geoTarget[geo]; if (subs !== null && geoTarget[newGeo].charAt(0) === '%') { geoTarget[newGeo] = utils.substitute(geoTarget[newGeo], subs); } if ((newGeo.search(/^[A-Z]{2}$/) !== -1) || Array.isArray(continents[newGeo]) || Array.isArray(regions[newGeo])) { urlObj = parseUrl(geoTarget[newGeo]); if (!(urlObj.host || urlObj.pathname)) { throw new Error(`Invalid redirect rule for "${route.on}", bad geoTarget (${geo}) redirect destination URL: ${geoTarget[newGeo]}`); } } } } route.geoTarget = geoTarget; } else { route.geoTarget = null; } // Set the redirect code (defaultCode by default) route.code = (typeof route.code !== 'undefined' && route.code >= 300 && route.code < 400) ? route.code : this.defaultRedirectCode; // Keep the parameters (false by default) route.keepParams = (typeof route.keepParams === 'boolean') ? route.keepParams : false; }; /** * Prepare and validate a rewrite route * * @memberof RouteTable * @private * @param {object} route - The route object */ RouteTable.prototype.prepRewriteRoute = function (route) { try { if (typeof route.rewrite !== 'string' || route.rewrite.length === 0) { throw new Error('Missing or invalid rewrite pattern.'); } route.pattern = new RegExp(route.rewrite); route.port = (typeof route.port === 'number' && route.port > 0 && route.port < 65536) ? route.port : 0; if (typeof route.redirectCode !== 'number') { route.redirectCode = 0; } else if (route.redirectCode !== 0 && (route.redirectCode < 301 || route.redirectCode > 308)) { throw new Error('Invalid redirect code.'); } if (typeof route.matchParams === 'undefined') { route.matchParams = false; } if (typeof route.replace === 'undefined') { route.replace = ''; } if (route.replace.search(/^(http|https)\:/i) !== -1 && route.redirectCode === 0) { route.redirectCode = this.defaultRedirectCode; } if (typeof route.status !== 'number') { route.status = 0; } else if (route.status !== 0 && (route.status < 400 || route.status > 505)) { throw new Error('Invalid status code.'); } if (typeof route.isLast !== 'boolean') { route.isLast = false; } } catch (error) { throw new Error(`Rewrite rule error (${route.on}): ${error.message}`); } }; /** * Check request hostname against host matching regex * if applicable, and return the case corrected and adjusted path key. * * @memberof RouteTable * @private * @param {object} req - The request object (RomRequest) * @returns {string|boolean} - The normalized path key or boolean false if no match */ RouteTable.prototype.checkBasicsAndNormalizePath = function (req) { return (this.isCaseSpecific === true ? req.path : req.normalizedPath) + (this.matchUsingQueryParams === true ? '?' + req.query : ''); }; /** * Handle forced protocol redirect * * @memberof RouteTable * @private * @param {object} req - The request object (RomRequest) * @param {object} route - The route object * @param {object} _args - Arguments from the route match * @returns {boolean} - true if handled, false if not */ RouteTable.prototype.handleProtocolRedirect = function (req, route, _args) { let url = formatUrl({ auth: req.auth, hash: req.hash, hostname: req.hostname, pathname: req.path, port: route.forcePort, protocol: route.forceProto, search: route.query }); req.log.debug(`Forced protocol redirect ${req.url} => ${route.forceProto.toUpperCase()}`); req.redirect(301, url); return true; // Handled }; /** * Handle matched redirect * * @memberof RouteTable * @private * @param {object} req - The request object (RomRequest) * @param {object} route - The route object * @param {object} args - Arguments from the route match * @returns {boolean} - true if handled, false if not */ RouteTable.prototype.handleMatchedRedirect = function (req, route, args) { let qString = ''; if (route.keepParams === true) { let qIndex = req.url.indexOf('?'); if (qIndex !== -1) { qString = req.url.substr(qIndex); } } if (route.geoTarget !== null) { let pageConts = '', pageRegs = '', pageString; req.log.debug(`${args.key} => (geotargeted) default is ${route.redirect}${qString} (${route.code})`); pageString = `<!DOCTYPE html><html><head><noscript><meta http-equiv="refresh" content="0;url=${route.redirect}></noscript><script>(function (d,w) {\ntry{\nvar a=d.cookie.match(/(^|;)\\s*countryCode\\s*=\\s*([^;]*)/), cc=(a?a[2]:""), rc=${route.code}, l="${route.redirect}";\nfunction isin(v,x) { for(var i=0;i<x.length;i++) { if(x[i]===v) { return true; } } return false; }\nif(cc==="") {}\n`; for (let geo in route.geoTarget) { if (route.geoTarget.hasOwnProperty(geo)) { if (geo.length === 2) { pageString += `else if(cc==="${geo}") { l="${route.geoTarget[geo]}${qString}"; }\n`; } else if (typeof regions[geo] !== 'undefined') { pageRegs += `else if(isin(cc,["${regions[geo].join('","')}"])) { l="${route.geoTarget[geo]}${qString}"; }\n`; } else if (typeof continents[geo] !== 'undefined') { pageConts += `else if(isin(cc,["${continents[geo].join('","')}"])) { l="${route.geoTarget[geo]}${qString}"; }\n`; } else { this.log.warn(`Bad geotargeting value for "${args.key}": ${geo}`); } } } req.send(200, pageString + pageRegs + pageConts + `w.location.replace(l);\n}catch(e){w.location.replace("${route.redirect}${qString}");}\n})(document,window);\n</script></head><body>&nbsp;</body></html>`); } else { // Send conventional redirect req.log.debug(`${args.key} => ${route.redirect}${qString} ${route.code}`); req.redirect(route.code, route.redirect + qString); } return true; // Handled }; /** * Handle matched rewrite * * @memberof RouteTable * @private * @param {object} req - The request object (RouteOMatic) * @param {object} route - The route object * @param {object} _args - Arguments from the route match * @returns {boolean} - True if handled, false to continue processing */ RouteTable.prototype.handleMatchedRewrite = function (req, route, _args) { try { let matchOn = (route && route.on) || '???', origParams, origPath = req.path, origUrl = req.url, params, path = origPath, qpos = req.url.indexOf('?'), url = origUrl; origParams = (qpos !== -1 ? req.url.substring(qpos + 1) : ''); params = origParams; // We match and have acceptable host/port/proto if (route.status !== 0) { // No actual rewrite needed, just return status code and finish up req.log.debug(`rule "${matchOn}" matched: ${url} => [${route.status}]`); req.send(route.status, ''); return true; // Handled } if (route.matchParams) { url = url.replace(route.pattern, route.replace); qpos = url.indexOf('?'); path = qpos !== -1 ? url.substr(0, qpos) : url; params = qpos !== -1 ? url.substr(qpos + 1) : ''; } else { url = path.replace(route.pattern, route.replace); qpos = url.indexOf('?'); if (qpos !== -1) { path = url.substr(0, qpos); if (params !== '') { url += '&' + params; } params = url.substr(qpos + 1); } else { path = url; if (params !== '') { qpos = path.length; url += '?' + params; } } } if (route.redirectCode !== 0) { req.log.debug(`rule "${matchOn}" matched: ${req.url} => ${url} [${route.redirectCode}]`); req.redirect(route.redirectCode, url); return true; // Handled } else { req.log.debug(`rule "${matchOn}" matched: ${req.url} => ${url}`); } if (url !== origUrl) { // Update the request object with rewritten details req.url = url; if (params !== origParams) { // Update query parameters req.query = {}; params.split('&').forEach(function handleParam(param) { let epos = param.indexOf('=') + 1; if (epos > 0) { req.query[param.substr(0, epos - 1)] = param.substr(epos); } else { req.query[param] = true; } }); } req.rewrite(url); return true; } } catch (err) { req.log.error(`Rewrite failure - ${err.message}`); req.error(500); return true; } return false; }; /** * Check regex routes * * @memberof RouteTable * @private * @param {object} req - The request object * @returns {boolean} - True if route matched, false if not */ RouteTable.prototype.checkRegexRoutes = function (req) { let key; // First, verify the hostname matches and the request path matches the base path, getting the path key key = this.checkBasicsAndNormalizePath(req); if (key === false) { return false; } for (let i = 0, rl = this.routes.length; i < rl; i++) { let r = this.routes[i], m = key.match(r.regex); if (m !== null && utils.doRuntimeChecks(req, r) === true) { let args = {}; for (let j = 0, ml = m.length; j < ml; j++) { args[j] = m[j]; } args.key = key; try { req.log.debug(`Request matched route for "${r.on}" in route-table ${this.id}`); if (r.forceProto.length !== 0 && req.proto !== r.forceProto) { // Force proto is set to not what we are using, so we need to redirect... return this.handleProtocolRedirect(req, r, args); } return r.action(req, r, args); } catch (err) { req.log.error(`Error in handler for route matching "${r.on}" with URL ${req.href}: ${err.message}`); req.error(500); return true; } } } return false; }; /** * Check Trie routes * * @memberof RouteTable * @private * @param {object} req - The request object * @returns {object} - null if no match, status and page data otherwise */ RouteTable.prototype.checkTrieRoutes = function (req) { let key, result; // First, verify the request path matches the base path, getting the path key key = this.checkBasicsAndNormalizePath(req); if (key === false || key.length === 0) { return false; } // Check the Trie for a match result = this.trie.find(key, req); if (result !== null) { let args = { 0: result.match, 1: key.slice(result.match.length), key: key }; if (typeof result.data.postMatchRE === 'object' && result.data.postMatchRE !== null && result.data.postMatchRE instanceof RegExp && args[1].search(result.data.postMatchRE) === -1) { // The postMatch option was set and it did not match, so fail this return false; } try { req.log.debug(`Request matched route for "${result.data.on}" in route-table ${this.id}`); if (result.data.forceProto.length !== 0 && req.proto !== result.data.forceProto) { // Force proto is set to not what we are using, so we need to redirect... this.handleProtocolRedirect(req, result.data, args); } else { result.data.action(req, result.data, args); } } catch (err) { req.log.error(`Error in handler for route matching "${result.data.on}" with URL ${req.href}: ${err.message}`); req.error(500); } return true; } return false; }; /** * Return the correct route resolver * * @memberof RouteTable * @public * @returns {function} - The route resolver function to use */ RouteTable.prototype.getResolver = function () { return this.resolver; }; module.exports = RouteTable;