helpers/jrh_express.js

/**
 * @module helpers/jrh_express
 * @author jesse reichler <mouser@donationcoder.com>
 * @copyright 11/2/19

 * @description
 * Collection of helper functions for use with the nodejs express web framework
*/

"use strict";


// modules
const passport = require("passport");

const util = require("util");

// our helper modules
const JrResult = require("./jrresult");




//---------------------------------------------------------------------------
// constants
const defReplyErrorTypeAuthToken = "authToken";
//---------------------------------------------------------------------------











//---------------------------------------------------------------------------
/**
 * Async Wrapper around the express/passport passport.authenticate function
 *
 * The passport library uses callbacks instead of promises and aync functions, so we wrap it here in a function that let's us use it like an async function for ease of use.
 * @see <a href="https://github.com/jaredhanson/passport/issues/605">passport docs</a>
 *
 * @param {*} authOptions
 * @param {*} provider
 * @param {*} providerNiceLabel
 * @param {*} req
 * @param {*} res
 * @param {*} next
 * @param {*} jrResult - our JrResult class with info about success or errors; pass in a valid instance and it is updated
 * @returns a simple object with passport user data
 */
async function asyncPassportAuthenticate(jrContext, authOptions, provider, providerNiceLabel) {
	// first we make a promise out of passport.authenticate then await on it

	let userPassport;

	// create promise wrapper around passport.authenticate
	const passportPromiseAuth = (req, res, next) => new Promise((resolve, reject) => {
		passport.authenticate(provider, authOptions, async (err, inuserPassport, info) => {
			if (err || !inuserPassport) {
				// add error
				const msg = err || info;
				jrContext.pushError("Error authenticating " + providerNiceLabel + ": " + JrResult.passportOrJrResultErrorAsString(msg) + ".");
				resolve();
			}
			// success
			userPassport = inuserPassport;
			// success resolve
			resolve();
		})(req, res, next);
	});


	// wait for promise resolution
	try {
		// now wait for the passport authenticate promise to run; note there is no case where it rejects; we could do this differently and catch an error
		await passportPromiseAuth(jrContext.req, jrContext.res, jrContext.next);
	} catch (err) {
		// unexpected error
		jrContext.pushError(err.message);
	}

	// return userPassport data object
	return userPassport;
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
// ASYNC wrapper around passport req.login
/**
 * Async Wrapper around the express req.logIn function
 *
 * The express library uses callbacks instead of promises and aync functions, so we wrap it here in a function that let's us use it like an async function for ease of use.
 * @see <a href="https://github.com/jaredhanson/passport/issues/605">passport docs</a>
 *
 * @param {*} userPassport - simple object with user data to log in the user
 * @param {*} errorMessage - text of errror message added to jrResult on error
 * @param {*} req - express request
 * @param {*} jrResult - our JrResult class with info about success or errors; pass in a valid instance and it is updated
 */
async function asyncPassportReqLogin(jrContext, loginOptions, userPassport, errorMessage) {
	// first we make a promise out of req.logIn then await on it

	// again create promise for passport req.login
	const passportPromiseReqLogin = (ijrContext) => new Promise((resolve, reject) => {
		// actually login the user
		ijrContext.req.logIn(userPassport, loginOptions, async (ierr) => {
			if (ierr) {
				// error (exception) logging them in
				ijrContext.pushError(errorMessage + ": " + JrResult.passportOrJrResultErrorAsString(ierr));
				resolve(ijrContext);
			}
			// success resolve
			resolve(ijrContext);
		})(ijrContext);
	});

	try {
		// now wait for the passport req login promise to run; note there is no case where it rejects; we could do this differently and catch an error
		await passportPromiseReqLogin(jrContext);
	} catch (err) {
		// unexpected error
		jrContext.pushError(err.message);
	}
}
//---------------------------------------------------------------------------

























//---------------------------------------------------------------------------
/**
 * Suggested code from express builder sample applications; used when creating server and binding it to ip+port
 *
 * @param {*} portval
 * @returns port value (possibly provided as string), converted to an integer if possible
 */
function normalizePort(portval) {
	const port = parseInt(portval, 10);
	if (Number.isNaN(port)) {
		// named pipe
		return portval;
	}
	if (port >= 0) {
		// port number
		return port;
	}
	return false;
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * Helper debug function that generates a list (object) of express middleware for display
 * @see <a href="https://github.com/yshing/express-list-middleware/blob/master/index.js">github code</a>
 * @see <a href="https://github.com/yshing/express-repl-toolkit/tree/master/tools">github code</a>
 * ##### Notes
 *  * Uses an interesting trick to get file and line number where the middleware is located
 *
 * @param {object} app - the express app object
 * @returns object containing all middleware, suitable for json stringifcation to console, etc.
 */
function calcExpressMiddleWare(app) {
	const jrdebug = require("./jrdebug");
	//
	let middlewareName, middlewareHint, extraDebugInfo;
	const appStack = app._router.stack;
	return Array.prototype.map.call(appStack, (middleware, index) => {
		middlewareName = middleware.handle.name;
		middlewareHint = "";

		// jrdebug.debugObj(middleware, middlewareName);
		middlewareHint = calcExpressMiddlewareRouterHint(middleware);
		if (middlewareHint) {
			middlewareName += " " + middlewareHint;
		}

		const lineInfo = calcExpressMiddlewareGetFileLine(middleware.handle);
		let retv = index + ". " + (middlewareName || "<anonymous function>");
		if (false || (lineInfo && !lineInfo.startsWith("at Function.handle") && !lineInfo.startsWith("at serveStatic"))) {
			retv += " " + lineInfo;
		}
		return retv;
	});
}


/**
 * Helper funciton to try to get function file line from handle
 *
 * @param {*} handler
 * @returns string specifying the line number of the handler
 */
function calcExpressMiddlewareGetFileLine(handler) {
	const jrdebug = require("./jrdebug");

	// This is a little trick we found to get the line number
	// ATTN: But on 3/31/20 it stopped working, due to our custom error handler catching this exception; we fixed the custom error catcher so it works now but I'm leaving this a flag so it can be bypassed if it ceases to work in the future
	const flagTryGetFileLine = true;
	//
	if (!flagTryGetFileLine) {
		return "[file line n/a]";
	}

	try {
		// trigger an ignorable exception (caught in arserver and ignored)
		handler("IGNORE_EXCEPTION");
	} catch (e) {
		let retstr = e.stack.split("\n")[1];
		retstr = retstr.trim();
		return retstr;
	}
	return null;
}



/**
 * Get a hint for a router middleware?
 *
 * @param {*} middlewareHandle
 */
function calcExpressMiddlewareRouterHint(middleware) {
	const jrhMisc = require("./jrh_misc");
	let hintstr = "";

	// extra debug info?
	if (middleware.handle.appRoomDebugInfo) {
		hintstr += jrhMisc.objToString(middleware.handle.appRoomDebugInfo, true);
	}

	if (middleware.regexp) {
		let rhint;
		if (middleware.regexp.source) {
			rhint = middleware.regexp.source;
		} else {
			rhint = middleware.regexp;
		}
		if (rhint !== "^\\/?(?=\\/|$)") {
			if (hintstr !== "") {
				hintstr += " ";
			}
			hintstr += "[regex:" + rhint + "]";
		}
	}
	return hintstr;
}




/**
 * Helper debug function the generates a list (object) of all paths registered to the express app
 *
 * @param {object} expressApp
 * @returns object containing all routes (and whether they are get|post|all)
 */
function calcExpressRoutePathData(expressApp) {
	const siteRoutes = expressApp._router.stack;

	const routes = [];
	siteRoutes.forEach(calcExpressRoutePathDataRouteArray.bind(this, routes, []));
	return routes;
}
//---------------------------------------------------------------------------










//---------------------------------------------------------------------------
/**
 * Internal helper debug function the helps to generate a list (object) of all paths registered to the express app
 * @private
 * @param {*} routes
 * @param {*} routepath
 * @param {*} layer
 */
function calcExpressRoutePathDataRouteArray(routes, routepath, layer) {
	if (layer.route) {
		layer.route.stack.forEach(calcExpressRoutePathDataRouteArray.bind(this, routes, routepath.concat(calcExpressRoutePathDataSplit(layer.route.path))));
	} else if (layer.name === "router" && layer.handle.stack) {
		routes.push("");
		layer.handle.stack.forEach(calcExpressRoutePathDataRouteArray.bind(this, routes, routepath.concat(calcExpressRoutePathDataSplit(layer.regexp))));
	} else if (layer.method) {
		const pathstr = layer.method.toUpperCase() + " /" + routepath.concat(calcExpressRoutePathDataSplit(layer.regexp)).filter(Boolean).join("/");
		routes.push(pathstr);
	}
}


/**
 * Internal helper debug function the helps to generate a list (object) of all paths registered to the express app.
 * Splits on path separator.
 * @private
 * @see <a href="https://github.com/expressjs/express/issues/3308">github code</a>
 *
 * @param {*} thing
 * @returns split of path
 */
function calcExpressRoutePathDataSplit(thing) {
	if (typeof thing === "string") {
		return thing.split("/");
	}
	if (thing.fast_slash) {
		return "";
	}
	const match = thing.toString().replace("\\/?", "").replace("(?=\\/|$)", "$").match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//);
	return match ? match[1].replace(/\\(.)/g, "$1").split("/") : "<complex:" + thing.toString() + ">";
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * Express helper function to look for property in request BODY or QUERY and return it, or defaultVal if not present
 *
 * @param {object} req - express request object
 * @param {string} key - key to look for in request body or query
 * @param {*} defaultVal - to return if key not found in request
 * @returns req.body[key] or req.query[key] or defaultVal
 */
function reqVal(req, key, defaultVal) {
	// return query param or post val or default
	if (req.body[key] !== undefined && req.body[key] !== null) {
		return req.body[key];
	}
	if (req.query[key] !== undefined && req.query[key] !== null) {
		return req.query[key];
	}
	return defaultVal;
}


/**
 * Express helper to look for property in request BODY or QUERY and return it, or defaultVal if not present
 *
 * @param {object} req - express request object
 * @param {string} key - key to look for in request body or query
 * @param {int} min - min int to floor the value to
 * @param {int} max - max int to ceil the value to
 * @param {int} defaultVal - to return if key not found in request
 * @returns req.body[key] or req.query[key] cast to int in range [min,max] or defaultVal
 */
function reqValAsInt(req, key, min, max, defaultVal) {
	// get val as int and min and max it
	let val = Number(reqVal(req, key, defaultVal));
	if (min !== null) {
		val = Math.max(val, min);
	}
	if (max !== null) {
		val = Math.min(val, max);
	}
	return val;
}


/**
 * Express helper to look for property in request BODY or QUERY and return it, or defaultVal if not present
 *
 * @param {object} req - express request object
 * @param {string} key - key to look for in request body or query
 * @param {array} valueList - list of values that are acceptable
 * @param {*} defaultVal - to return if key not found in request
 * @returns req.body[key] or req.query[key] as long as they are present in valueList, otherwise defaultVal
 */
function reqValFromList(req, key, valueList, defaultVal) {
	const val = reqVal(req, key, defaultVal);
	if (valueList.indexOf(val) > -1) {
		return val;
	}
	return defaultVal;
}


/**
 * Express helper to look for all properties with a given prefix
 *
 * @param {*} req - express request object
 * @param {string} prefix - the prefix string
 * @param {*} keyList
 * @returns an associative array of all key=>value pairs of request properties where key starts with "prefix_key" and key is in keyList
 */
function reqPrefixedValueArray(req, prefix, keyList) {
	// look for ALL values for prefix+"_"+key and return an associative array of them
	const valArray = {};
	let fieldName;
	let val;
	keyList.forEach((key) => {
		fieldName = prefix + "_" + key;
		val = reqVal(req, fieldName, undefined);
		if (val !== undefined && val !== "") {
			// store it
			valArray[key] = val;
		}
	});

	return valArray;
}


/**
 * Express helper to look for all items with a given prefix
 *
 * @param {*} req - express request object
 * @param {string} prefix - the prefix string
 * @returns an array of all form values with id prefix_ (used for checklists)
 */
function reqPrefixedCheckboxItemIds(reqbody, prefix) {
	// look for ALL values for prefix+"_"+key and return an associative array of them
	const valArray = [];
	let id;
	const keys = Object.keys(reqbody);
	const prefixlen = prefix.length;
	keys.forEach((keyname) => {
		if (keyname.startsWith(prefix)) {
			id = keyname.substr(prefixlen);
			valArray.push(id);
		}
	});

	return valArray;
}



/**
 * Deletes the varname from session data
 *
 * @param {obj} req
 * @param {string} varName
 */
function forgetSessionVar(jrContext, varName) {
	if (jrContext.req.session && jrContext.req.session[varName] !== undefined) {
		delete jrContext.req.session[varName];
	}
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
function getRequestLogString(jrContext) {
	// ATTN: should this be req user id?
	const str = util.format("url='%s' userid='%s' ip='%s'", jrContext.req.url, this.getReqPassportUsrid(jrContext), jrContext.req.ip);
	return str;
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * @see https://expressjs.com/en/api.html#req
 *
 * @param {*} req
 * @returns the url of the request
 */
function reqUrlWithPath(req) {
	if (!req) {
		return "/";
	}
	return req.baseUrl + "/" + req.path;
}


/**
 * @see https://expressjs.com/en/api.html#req
 *
 * @param {*} req
 * @returns the original url of the request
 */
function reqOriginalUrl(req) {
	if (!req) {
		return "/";
	}
	return req.originalUrl;
}
//---------------------------------------------------------------------------










//---------------------------------------------------------------------------
/**
 * We expect a json encodd string in the request body under a certain field (post var) name.
 * Get it and parse it to json and return the json
 * Caller should check jrResult for any error
 *
 * @param {*} req - express request object
 * @param {*} keyName - name of the post let to get the data from
 * @param {*} jrResult - errors are pushed into here.
 * @returns the json object encoded by the post var
 */
function parseReqGetJsonField(jrContext, keyName) {
	if (!jrContext.req.body[keyName]) {
		jrContext.pushError("Missing json data, expected in post variable " + keyName + ".");
		return null;
	}

	let jsonVal;
	try {
		jsonVal = jrContext.req.body[keyName];
	} catch (e) {
		jrContext.pushException("Invalid json data in field " + keyName, e);
		return null;
	}

	return jsonVal;
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
function getReqPassportUsr(jrContext) {
	// see 	http://toon.io/understanding-passportjs-authentication-flow/
	// NOTE: this says not to call req.session.passport.user directly? https://stackoverflow.com/questions/27055744/express-passport-js-req-user-versus-req-session-passport-user
	// old code: return (req.session && req.session.passport) ? req.session.passport.user : defaultVal;
	return (jrContext.req && jrContext.req.user) ? jrContext.req.user : undefined;
}


function getReqPassportUsrStringified(jrContext, defaultVal) {
	const reqUser = getReqPassportUsr(jrContext);
	return (reqUser) ? JSON.stringify(jrContext.req.user, null, "  ") : defaultVal;
}


function getReqPassportUsrId(jrContext, defaultVal) {
	// ATTN: should we be checking .id or .userId?
	const reqUser = getReqPassportUsr(jrContext);
	return (reqUser) ? reqUser.id : undefined;
}
//---------------------------------------------------------------------------
















//---------------------------------------------------------------------------
/**
 * Send a json reply to the express Response object
 *
 * @param {*} res - the express response object
 * @param {int} status - status code to send
 * @param {string} str - success string (send as value of success key)
 * @param {*} obj - other data to send in reply
 */
function sendJsonDataSuccess(jrContext, successStr, obj) {
	// success status code
	const status = 200;
	//
	const msg = successStr || true;
	// if no success string is provided, return success = true
	const data = {
		success: msg,
		...obj,
	};
	//
	jrContext.res.status(status).send(data);
}


/**
 * Send a JrResult as a reply, either as an error or success, depending on jrResult data
 *
 * @param {*} res - the express repsonse object
 * @param {int} status - the http status code
 * @param {*} jrResult - the jrResult object to send
 */
function sendJsonResult(jrContext, status) {
	// send an error or a success
	// is it error?
	if (jrContext.isError()) {
		sendJsonError(jrContext, status, jrContext.getErrorsAsString(), undefined);
		return;
	}
	// it's a success
	sendJsonDataSuccess(jrContext, jrContext.getSuccessAsString(), undefined);
}


/**
 * Send a json reply to the response, which is an error
 *
 * @param {*} res - the express response object
 * @param {int} status - the http status code (error)
 * @param {string} errorStr - the value for the error key
 * @param {string} errorType - the value for the error key (OPTIONAL)
 */
function sendJsonError(jrContext, status, errorStr, errorType) {
	// error message passed to us, or just "true" if for some reason there is no error in context
	const emsg = errorStr || true;
	const data = {
		error: emsg,
		errorType,
	};
	//
	jrContext.res.status(status).send(data);
}


/**
 * Send a json result that indicates an api token error, with status 403
 *
 * @param {*} jrContext - with the details of the error
 */
function sendJsonErrorAuthToken(jrContext) {
	// token status code error
	const status = 403;
	// error message as described in jrContext, or just "true" if for some reason there is no error in context
	const emsg = jrContext.getErrorsAsString() || true;
	//
	sendJsonError(jrContext, status, emsg, defReplyErrorTypeAuthToken);
}


/**
 * Send a json result that indicates an ACL error, with status 403
 *
 * @param {*} jrContext
 * @param {string} permission - permission that the user is denied
 * @param {string} permissionObjType - type of permission to complain about
 * @param {string} permissionObjId - actual object id to complain about
 */
function sendJsonErorrAcl(jrContext, permission, permissionObjType, permissionObjId) {
	// acl status code error
	const status = 403;
	//
	let errorMessage = "you do not have permission to " + permission;
	if (permissionObjType) {
		errorMessage += " on " + permissionObjType;
	}
	if (permissionObjId) {
		errorMessage += " #" + permissionObjId;
	}
	//
	sendJsonError(jrContext, status, errorMessage, "acl");
}
//---------------------------------------------------------------------------




















// export the class as the sole export
module.exports = {
	defReplyErrorTypeAuthToken,

	asyncPassportAuthenticate,
	asyncPassportReqLogin,

	normalizePort,

	calcExpressMiddleWare,
	calcExpressRoutePathData,

	reqVal,
	reqValAsInt,
	reqValFromList,
	reqPrefixedValueArray,
	reqPrefixedCheckboxItemIds,
	forgetSessionVar,

	getRequestLogString,

	reqUrlWithPath,
	reqOriginalUrl,

	parseReqGetJsonField,

	getReqPassportUsr,
	getReqPassportUsrStringified,
	getReqPassportUsrId,

	sendJsonDataSuccess,
	sendJsonResult,
	sendJsonError,
	sendJsonErrorAuthToken,
	sendJsonErorrAcl,
};