/**
* @module controllers/arserver
* @author jesse reichler <mouser@donationcoder.com>
* @copyright 5/1/19 - 3/31/20
* @description
* This module defines the main class representing the server system that sets up the web server and handles all requests.
* It is the central object in the project.
*/
"use strict";
// database imports
const mongoose = require("mongoose");
// express related modules
const httpErrors = require("http-errors");
const express = require("express");
const cookieParser = require("cookie-parser");
const session = require("express-session");
const csurf = require("csurf");
const connectMongo = require("connect-mongo");
const http = require("http");
const bodyParser = require("body-parser");
const https = require("https");
const favicon = require("serve-favicon");
// json web tokens
const jsonwebtoken = require("jsonwebtoken");
// passport authentication stuff
const passport = require("passport");
const passportLocal = require("passport-local");
const passportFacebook = require("passport-facebook");
const passportTwitter = require("passport-twitter");
const passportGoogle = require("passport-google-oauth20");
const passportJwt = require("passport-jwt");
// misc node core modules
const path = require("path");
const fs = require("fs");
const assert = require("assert");
const util = require("util");
// misc 3rd party modules
const gravatar = require("gravatar");
// profiler (only used when PROFILE option set)
let profiler; // = require("v8-profiler-next");
// requirement service locator
const jrequire = require("../helpers/jrequire");
// our helper modules
const jrhMisc = require("../helpers/jrh_misc");
const jrhMongo = require("../helpers/jrh_mongo");
const jrhExpress = require("../helpers/jrh_express");
const jrlog = require("../helpers/jrlog");
const jrdebug = require("../helpers/jrdebug");
const jrconfig = require("../helpers/jrconfig");
const JrContext = require("../helpers/jrcontext");
const JrResult = require("../helpers/jrresult");
const jrhHandlebars = require("../helpers/jrh_handlebars");
const jrhText = require("../helpers/jrh_text");
const jrhRateLimiter = require("../helpers/jrh_ratelimiter");
// constants
const appdef = jrequire("appdef");
/**
* The main class representing the server system that sets up the web server and handles all requests.
*
* @class AppRoomServer
*/
class AppRoomServer {
//---------------------------------------------------------------------------
// constructor
constructor() {
// set flag
this.didSetup = false;
// csrf
this.csrfInstance = undefined;
this.gravatarOptions = undefined;
//
this.serverHttps = undefined;
this.serverHttp = undefined;
//
this.needsShutdown = false;
//
this.models = {};
//
this.procesData = {};
//
this.appInfo = {};
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// accessors, directory
setAppInfo(val) {
this.appInfo = val;
}
getAppinfo() {
return this.appInfo;
}
getSourceDir() {
return path.resolve(__dirname, "..");
}
getSourceSubDir(relpath) {
return path.join(this.getSourceDir(), relpath);
}
getInstallDir() {
return path.resolve(__dirname, "../..");
}
getInstallSubDir(relpath) {
return path.join(this.getInstallDir(), relpath);
}
getLogDir() {
// default is in the parent folder of source dir in /local/logs subdir
const dir = this.getConfigValDefault(appdef.DefConfigKeyLoggingDirectory, "./local/logs");
return jrhMisc.resolvePossiblyRelativeDirectory(dir, this.getInstallDir());
}
getNormalConfigDir() {
// default is in the parent folder of source dir in /config subdir
return this.getInstallSubDir("local/config");
}
getSourceConfigDir() {
// default is in the parent folder of source dir in /config subdir
return this.getSourceSubDir("config");
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// accessors, continued
getNeedsShutdown() {
return this.needsShutdown;
}
setNeedsShutdown(val) {
this.needsShutdown = val;
}
getModels() {
return this.models;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// getting options via jrconfig
//
getOptionHttp() { return this.getConfigVal(appdef.DefConfigKeyServerHttp); }
getOptionHttpPort() { return this.getConfigVal(appdef.DefConfigKeyServerHttpPort); }
getOptionHttps() { return this.getConfigVal(appdef.DefConfigKeyServerHttps); }
getOptionHttpsKey() { return this.getConfigVal(appdef.DefConfigKeyServerHttpsKey); }
getOptionHttpsCert() { return this.getConfigVal(appdef.DefConfigKeyServerHttpsCert); }
getOptionHttpsPort() { return this.getConfigVal(appdef.DefConfigKeyServerHttpsPort); }
getOptionSiteDomain() { return this.getConfigVal(appdef.DefConfigKeyServerSiteDomain); }
getOptionDebugTagEnabledList() { return this.getConfigVal(appdef.DefConfigKeyDebugTags); }
getOptionProfileEnabled() { return this.getConfigVal(appdef.DefConfigKeyProfile); }
getOptionUseFullRegistrationForm() { return this.getConfigVal(appdef.DefConfigKeyAccountSignupFullRegForm); }
// see https://stackoverflow.com/questions/2683803/gravatar-is-there-a-default-image
/*
404: do not load any image if none is associated with the email hash, instead return an HTTP 404 (File Not Found) response
mm: (mystery-man) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash)
identicon: a geometric pattern based on an email hash
monsterid: a generated 'monster' with different colors, faces, etc
wavatar: generated faces with differing features and backgrounds
retro: awesome generated, 8-bit arcade-style pixelated faces
blank: a transparent PNG image (border added to HTML below for demonstration purposes)
*/
getOptionsGravatar() { return this.getConfigVal(appdef.DefConfigKeyAccountGravatarOptions); }
getEmergencyAlertContactsPrimary() { return this.getConfigVal(appdef.DefConfigKeyEmergencyAlertPrimaryEmails); }
getEmergencyAlertContactsSecondary() { return this.getConfigVal(appdef.DefConfigKeyEmergencyAlertSecondaryEmails); }
getLoggingAnnouncement() { return this.getConfigVal(appdef.DefConfigLoggingAnnouncement); }
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getExpressApp() { return this.expressApp; }
getJrConfig() { return jrconfig; }
getConfigVal(...args) { return jrconfig.getVal(...args); }
getConfigValDefault(arg, defaultVal) { return jrconfig.getValDefault(arg, defaultVal); }
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getModelClassAclName(modelClassName) {
return this.models[modelClassName].getAclName();
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getServerIp() {
return jrhMisc.getServerIpAddress();
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getDebugKeyName() {
// what to use for debug word, and log filename, profile output filename, etc.
return appdef.DefDebugbKeyName;
}
getLogFileBaseName() {
// for log files
return this.getConfigVal(appdef.DefConfigKeyLogFileBaseName);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
calcFullDbUrl() {
let url = this.getConfigVal(appdef.DefConfigKeyDbBaseUrl) + "/";
// during testing the config might override this
url += this.getConfigVal(appdef.DefConfigKeyDbName);
return url;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getAboutInfo() {
const appInfo = this.getAppinfo();
const data = {
appName: appInfo.programName,
appVersion: appInfo.programVersion,
appVersionDate: appInfo.programVersionDate,
appAuthor: appInfo.programAuthor,
appDescription: appInfo.programDescription,
libName: appdef.DefLibName,
libVersion: this.getVersionLib(),
libVersionDate: appdef.DefLibVersionDate,
libAuthor: appdef.DefLibAuthor,
libDescription: appdef.DefLibDescription,
};
return data;
}
getMiscInfo() {
const data = {
database: this.calcFullDbUrl(),
loggingPath: this.getLogDir() + "/" + this.getLogFileBaseName() + ".log",
LoggingAnnouncement: this.getLoggingAnnouncement(),
serverIp: this.getServerIp(),
developmentMode: this.isDevelopmentMode(),
};
return data;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getBaseServerIpHttp() {
return this.getBaseServerUrl("http", this.serverHttp);
}
getBaseServerIpHttps() {
return this.getBaseServerUrl("https", this.serverHttps);
}
getBaseServerUrl(protocolStr, server) {
if (server == null) {
return null;
}
let serverIp = this.getServerIp();
const addr = server.address();
if (typeof addr === "string") {
serverIp += ":" + addr;
} else {
serverIp += ":" + addr.port;
}
const url = protocolStr + "://" + serverIp;
return url;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setup() {
if (this.didSetup) {
// jrdebug.debug("Ignoring additional call to arserver.setup().");
return;
}
// set flag
this.didSetup = true;
// do one-time setup
this.setupPreConfig();
this.processConfig();
this.setupPostConfig();
// Display some starting info
this.announceStartingStuff();
}
setupPreConfig() {
// perform global configuration actions that are shared and should be run regardless of the cli app or unit tests
// this happens BEFORE processing config file, so no config info is known yet
// save info about startup time
this.procesData.started = Date.now();
// load up requirements that avoid circular dependencies
this.setupLateRequires();
// setup debugger
jrdebug.setup(this.getDebugKeyName());
// show some info about app
const appInfo = this.getAppinfo();
jrdebug.debugf("%s v%s (%s) by %s", appInfo.programName, appInfo.programVersion, appInfo.programVersionDate, appInfo.programAuthor);
jrdebug.debugf("%s v%s (%s) by %s", appdef.DefLibName, this.getVersionLib(), appdef.DefLibVersionDate, appdef.DefLibAuthor);
// try to get server ip
const serverIp = this.getServerIp();
jrconfig.setServerFilenamePrefixFromServerIp(serverIp);
// Set base directory to look for config files -- caller can modify this, and discover them
jrconfig.setConfigDirs(this.getSourceConfigDir(), this.getNormalConfigDir());
}
processConfig() {
// now parse commandline/config/env/ etc.
jrconfig.parse();
// set any values based on config
// enable debugging based on DEBUG tags field
jrdebug.setDebugTagEnabledList(this.getOptionDebugTagEnabledList());
// discover addon plugins, must be done after processing config file
this.discoverAndInitializeAddonPlugins();
// discover addon appFrameworks
this.discoverAndInitializeAddonAppFrameworks();
}
async setupPostConfig() {
// setup done AFTER config is loaded
// setup loggers -- can we wait until after config so that config can tell us log dir?
if (true) {
this.setupLoggers();
}
// view/template extra stuff
this.setupViewTemplateExtras();
// cache any options for faster access
this.cacheMiscOptions();
// misc global hooks
this.setupGlobalHooks();
}
announceStartingStuff() {
jrdebug.debugf("%s.", this.getLoggingAnnouncement());
jrdebug.debugf("Using database %s.", this.calcFullDbUrl());
jrdebug.debugf("Logging to %s.", this.getLogFileBaseName() + ".log");
jrdebug.debugf("Running on server: %s.", this.getServerIp());
// tell user if we are running in development mode
if (this.isDevelopmentMode()) {
jrdebug.debug("Running in development mode (verbose errors shown).");
}
const debugTagString = jrdebug.getDebugTagEnabledListAsNiceString();
if (debugTagString) {
jrdebug.debug("Debug log tags: " + debugTagString);
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// just pass along to jrconfig
// this should be called BEFORE arserver.setup()
addEarlyConfigFileSet(filename) {
jrconfig.addEarlyConfigFileSet(filename);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
registerModel(modelRequireClassName, modelClass) {
this[modelRequireClassName] = modelClass;
this.models[modelRequireClassName] = modelClass;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupLateRequires() {
// controllers
this.crudAid = jrequire("crudaid");
this.aclAid = jrequire("aclaid");
this.sendAid = jrequire("sendaid");
this.setupAid = jrequire("setupaid");
// model requires
this.registerModel("AppModel", jrequire("models/app"));
this.registerModel("ConnectionModel", jrequire("models/connection"));
this.registerModel("FileModel", jrequire("models/file"));
this.registerModel("LogModel", jrequire("models/log"));
this.registerModel("LoginModel", jrequire("models/login"));
this.registerModel("RoleModel", jrequire("models/role"));
this.registerModel("OptionModel", jrequire("models/option"));
this.registerModel("RoleModel", jrequire("models/role"));
this.registerModel("RoomModel", jrequire("models/room"));
this.registerModel("RoomdataModel", jrequire("models/roomdata"));
this.registerModel("SessionModel", jrequire("models/session"));
this.registerModel("UserModel", jrequire("models/user"));
this.registerModel("VerificationModel", jrequire("models/verification"));
this.registerModel("SubscriptionModel", jrequire("models/subscription"));
this.registerModel("ModQueueModel", jrequire("models/modqueue"));
this.registerModel("ModQueueModel", jrequire("models/modqueue"));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupLoggers() {
// setup singleton loggers
jrlog.setup(this.getLogFileBaseName(), this.getLogDir());
// winston logger files
jrlog.setupWinstonLogger(appdef.DefLogCategoryError, appdef.DefLogCategoryError);
jrlog.setupWinstonLogger(appdef.DefLogCategoryError404, appdef.DefLogCategoryError404);
jrlog.setupWinstonLogger(appdef.DefLogCategoryDebug, appdef.DefLogCategoryDebug);
jrlog.setupWinstonLogger(appdef.DefLogCategory, appdef.DefLogCategory);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupExpress() {
// create this.express
const expressApp = express();
// save expressApp for easier referencing later
this.expressApp = expressApp;
// early injection of pointer to this server into request
this.setupExpressEarlyInjections(expressApp);
// favicon
this.setupExpressFavIcon(expressApp);
// view and template stuff
this.setupExpressViews(expressApp);
// setup logging stuff
this.setupExpressLogging(expressApp);
// setup misc., parsers, etc.
this.setupExpressMiscParsers(expressApp);
// session, cookies, etc.
this.setupExpressSessionCookieStuff(expressApp);
// security stuff
this.setupExpressSecurity(expressApp);
// setup static file and bootstrap, jquery, etc.
this.setupExpressStatics(expressApp);
// any custom middleware?
this.setupExpressCustomMiddleware(expressApp);
// passport login system
this.setupExpressPassport(expressApp);
// routes
this.setupExpressRoutesCore(expressApp);
this.setupExpressRoutesSpecialized(expressApp);
// fallback error handlers
this.setupExpressErrorHandlers(expressApp);
}
setupExpressEarlyInjections(expressApp) {
// we are deciding whether we want this
if (false) {
expressApp.use((req, res, next) => {
// add pointer to us in the request?
req.arserver = this;
return next();
});
}
}
setupExpressFavIcon(expressApp) {
// see https://github.com/expressjs/serve-favicon
const staticAbsoluteDir = this.getSourceSubDir("static");
const faviconObj = favicon(path.join(staticAbsoluteDir, "favicon.ico"));
expressApp.use(faviconObj);
}
setupExpressViews(expressApp) {
// view file engine setup
expressApp.set("views", this.getSourceSubDir("views"));
// handlebar template ending
expressApp.set("view engine", "hbs");
}
setupExpressLogging(expressApp) {
// logging system for express httpd server - see https://github.com/expressjs/morgan
// by default this is displaying to screen
// see https://github.com/expressjs/morgan
const morganMiddleware = jrlog.setupMorganMiddlewareForExpressWebAccessLogging();
expressApp.use(morganMiddleware);
}
setupExpressMiscParsers(expressApp) {
// misc stuff
expressApp.use(express.json());
expressApp.use(bodyParser.urlencoded({ extended: true }));
// parse query parameters automatically
expressApp.use(express.query());
}
setupExpressSessionCookieStuff(expressApp) {
// ATTN: 4/8/20 - cookie stuff locks server listener so we dont exit cleanly??
// cookie support
expressApp.use(cookieParser());
// session store
// db session backend storage (we avoid file in case future cloud operation)
// connect-mongo see https://www.npmjs.com/package/connect-mongo
// ATTN: we could try to share the mongod connection instead of re-specifying it here; not clear what performance implications are
const mongoStoreOptions = {
url: this.calcFullDbUrl(),
autoRemove: "interval",
autoRemoveInterval: 600, // minutes
};
const MonstStore = connectMongo(session);
const sessionStore = new MonstStore(mongoStoreOptions);
// cookie options
const cookieOptions = {
secure: false,
};
// sesssion support
// see https://github.com/expressjs/session
const asession = session({
name: this.getConfigVal(appdef.DefConfigKeySessionIdName),
secret: this.getConfigVal(appdef.DefConfigKeySessionSecret),
resave: false,
cookie: cookieOptions,
saveUninitialized: false,
store: sessionStore,
});
expressApp.use(asession);
// ATTN: We need to remember this sessionStore so we can close it down on exit gracefully
this.rememberedSessionStore = sessionStore;
}
setupExpressSecurity(expressApp) {
// setup csrf, etc.
// see https://github.com/expressjs/csurf
this.csrfInstance = csurf({
cookie: false,
ignoreMethods: [], // we pass in empty array here because we are not using csurf as middleware and explicitly calling when we want it
});
// ATTN: we do NOT install it as middleware, we will use it explicitly only when we want it
// by calling some support functions we have written
}
setupExpressStatics(expressApp) {
// static resources serving
// setup a virtual path that looks like it is at staticUrl and it is served from staticAbsoluteDir
const staticAbsoluteDir = this.getSourceSubDir("static");
const staticUrl = "/static";
expressApp.use(staticUrl, express.static(staticAbsoluteDir));
jrdebug.cdebugf("misc", "Serving static files from '%s' at '%s", staticAbsoluteDir, staticUrl);
// setup bootstrap, jquery, etc.
const jsurl = staticUrl + "/js";
const cssurl = staticUrl + "/css";
const nodemodulespath = path.join(__dirname, "..", "node_modules");
this.setupExpressStaticRoute(expressApp, jsurl + "/bootstrap", path.join(nodemodulespath, "bootstrap", "dist", "js"));
this.setupExpressStaticRoute(expressApp, cssurl + "/bootstrap", path.join(nodemodulespath, "bootstrap", "dist", "css"));
this.setupExpressStaticRoute(expressApp, jsurl + "/jquery", path.join(nodemodulespath, "jquery", "dist"));
}
setupExpressStaticRoute(expressApp, urlPath, dirPath) {
const route = express.static(dirPath);
this.useExpressRoute(expressApp, urlPath, route, dirPath);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupExpressCustomMiddleware(expressApp) {
// setup any custom middleware
// see our documentation in JrResult, we have decided to not use automatic injection of JrResult data
// auto inject into render any saves session jrResult
// expressApp.use(JrResult.expressMiddlewareInjectSessionResult());
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupExpressRoutesCore(expressApp) {
// add routes to express app
// home page
this.setupRoute(expressApp, "/", "index");
// register/signup
this.setupRoute(expressApp, "/register", "register");
// login
this.setupRoute(expressApp, "/login", "login");
// logout
this.setupRoute(expressApp, "/logout", "logout");
// verifications
this.setupRoute(expressApp, "/verify", "verify");
// profile
this.setupRoute(expressApp, "/profile", "profile");
// admin
this.setupRoute(expressApp, "/admin", "admin");
// internals
this.setupRoute(expressApp, "/internals", "internals");
// analytics
this.setupRoute(expressApp, "/analytics", "analytics");
// testing
this.setupRoute(expressApp, "/test", "test");
// help/about
this.setupRoute(expressApp, "/help", "help");
// crud routes
const crudUrlBase = "/crud";
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/user", this.UserModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/login", this.LoginModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/verification", this.VerificationModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/connection", this.ConnectionModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/role", this.RoleModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/option", this.OptionModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/log", this.LogModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/session", this.SessionModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/modqueue", this.ModQueueModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/subscription", this.SubscriptionModel);
}
setupExpressRoutesSpecialized(expressApp) {
// add routes to express app
// app routes
this.setupRoute(expressApp, "/app", "app");
// room routes
this.setupRoute(expressApp, "/room", "room");
// api routes
this.setupRoute(expressApp, "/api", "api/api");
this.setupRoute(expressApp, "/api/app", "api/app");
this.setupRoute(expressApp, "/api/room", "api/room");
this.setupRoute(expressApp, "/api/roomdata", "api/roomdata");
// test stuff
this.setupRoute(expressApp, "/membersonly", "membersonly");
// crud routes
const crudUrlBase = "/crud";
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/app", this.AppModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/room", this.RoomModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/file", this.FileModel);
this.setupRouteGenericCrud(expressApp, crudUrlBase + "/roomdata", this.RoomdataModel);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupRoute(expressApp, urlPath, routeFilename) {
// require in the file in the routes directory so we can discover its functions
const requireRouteFileName = "../routes/" + routeFilename;
const route = require(requireRouteFileName);
// ok there are two ways that our route files can be written
// the first is by exporting a setupRouter function, in which case we call it with urlPath and it returns the router
// the older method just exports default router
//
if (route.setupRouter) {
const expressRouter = route.setupRouter(urlPath);
assert(expressRouter);
this.useExpressRoute(expressApp, urlPath, expressRouter, requireRouteFileName);
} else {
this.useExpressRoute(expressApp, urlPath, route, requireRouteFileName);
}
}
setupRouteGenericCrud(expressApp, urlPath, modelClass) {
// function to set up crud paths for a model
// create router using express
const router = express.Router();
// setup paths on it
this.crudAid.setupRouter(router, modelClass, urlPath);
// register it
this.useExpressRoute(expressApp, urlPath, router, "../controllers/crudaid");
// let app model know about its crud path
modelClass.setCrudBaseUrl(urlPath);
// now return the router for further work
return router;
}
useExpressRoute(expressApp, urlPath, route, dirPath) {
// register it with the express App
expressApp.use(urlPath, route);
// attempt to inject some internal debug helping info -- this is displayed when doing site internals web server info (see jrh_express.js function calcExpressMiddleWare())
route.appRoomDebugInfo = {
urlPath,
dirPath,
};
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupExpressPassport(expressApp) {
// setup passport module for login authentication, etc.
// provide callback function to help passport serialize a user
passport.serializeUser((profile, done) => {
// here we are converting from the profile object returned by the strategy, to the minimal user data stored in the SESSION object
// so we want this to be just enough to uniquely identify the user.
// profile is the user profile object returned by the passport strategy callback below, so we can decide what to return from that
// so in this case, we just return the profile object
jrdebug.cdebugObj("misc", profile, "serializeUser profile");
const userProfileObj = profile;
// call passport callback
done(null, userProfileObj);
});
// provide callback function to help passport deserialize a user
passport.deserializeUser((profile, done) => {
// we are now called with user being the minimum USER object we passed to passport earlier, which was saved in user's session data
// should we now find this user in the database and return the full user model? if so we will be fetching user database model on every request.
// the idea here is that our session data contains only the minimalist data returned by serializeUser()
// and this function gives us a chance to fully load a full user object on each page load, which passport will stick into req.user
// but we may not want to actually use this function to help passport load up a full user object from the db, because of the overhead and cost of doing
// that when it's not needed. So we are converting from the SESSION userdata to possibly FULLER userdata
// however, remember that we might want to check that the user is STILL allowed into our site, etc.
jrdebug.cdebugObj("misc", profile, "deserializeUser user");
const userProfileObj = profile;
// call passport callback
done(null, userProfileObj);
});
// setup passport strategies
this.setupPassportStrategies();
// hand passport off to express
expressApp.use(passport.initialize());
expressApp.use(passport.session());
}
setupPassportStrategies() {
// setup any login/auth strategies
this.setupPassportStrategyLocal();
this.setupPassportStrategyFacebook();
this.setupPassportStrategyTwitter();
this.setupPassportStrategyGoogle();
this.setupPassportJwt();
}
setupPassportStrategyLocal() {
// local username and password strategy
// see https://www.sitepoint.com/local-authentication-using-passport-node-js/
const Strategy = passportLocal.Strategy;
const strategyOptions = {
passReqToCallback: true,
usernameField: "usernameEmail",
};
// see http://www.passportjs.org/docs/configure/
passport.use(new Strategy(
strategyOptions,
async (req, usernameEmail, password, done) => {
// this is the function called when user tries to login
// so we check their username and password and return either FALSE or the user
// first, find the user via their password
// make jrContext with only req since we don't have access to the others
const jrContext = JrContext.makeNew(req, null, null);
jrdebug.cdebugf("misc", "In passport local strategy test with username=%s and password=%s", usernameEmail, password);
const user = await this.UserModel.mFindUserByUsernameEmail(usernameEmail);
if (!user) {
// not found
jrContext.pushFieldError("usernameEmail", "Username/Email-address not found");
return done(null, false, jrContext.result);
}
// ok we found the user, now check their password
const bretv = await user.testPlaintextPassword(password);
if (!bretv) {
// password doesn't match
jrContext.pushFieldError("password", "Password does not match");
return done(null, false, jrContext.result);
}
// password matches!
// update last login time
await user.updateLastLoginDate(jrContext, true);
// set cached user so we don't have to reload them
this.setCachedLoggedInUserManually(jrContext, user);
// return the minimal user info needed
// IMP NOTE: the profile object we return here is precisely what gets passed to the serializeUser function above
const userProfile = user.getMinimalPassportProfile();
// set virtual token of profile so observers can see HOW (and when) the user logged in
userProfile.token = this.makeVirtualLoginToken("usernamePassword");
// return it
return done(null, userProfile, jrContext.result);
},
));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupPassportJwt() {
// here we actually register 2 nearly identical jwt token passport strategies
// the first ("jwtHeader") is the real one used to access resources, by passing an access token in the auth header of a request
// the second ("jwtManualQueryOrPostBody") is used when we want to parse a token passed as a field in a form, for processing (for example when user wants to ask us to create a new access token from a refresh token)
//
// ExtractJwt lets us set priority for multiple ways to get the jwt from URL or from post body or from ath header; earlier takes higher priority
// see http://www.passportjs.org/packages/passport-jwt/#extracting-the-jwt-from-the-request
//
const ExtractJwt = passportJwt.ExtractJwt;
// the auth header one is the one we check on almost all requests, so we want it to validate the token and throw a complaint at autho time if something it wrong with it (expired, etc.)
this.setupPassportJwtNamedWithExtractors("jwtHeader", [ExtractJwt.fromAuthHeaderAsBearerToken()], (jrResult, tokenObj) => { return this.validateSecureTokenAccess(jrResult, tokenObj); });
// the manual body one is what we use when we want to let the user give us a token that we ourselves will validate and process
this.setupPassportJwtNamedWithExtractors("jwtManualQueryOrPostBody", [ExtractJwt.fromUrlQueryParameter("token"), ExtractJwt.fromBodyField("token")], null);
}
setupPassportJwtNamedWithExtractors(jwtStrategyName, extractorList, tokenValidationFunction) {
// see http://www.passportjs.org/packages/passport-jwt/
// for passport named strategies see: https://github.com/jaredhanson/passport/issues/412
const Strategy = passportJwt.Strategy;
const ExtractJwt = passportJwt.ExtractJwt;
const strategyOptions = {
secretOrKey: this.getConfigVal(appdef.DefConfigKeyTokenCryptoKey),
jwtFromRequest: ExtractJwt.fromExtractors(extractorList),
// we ignore expiration auto handling; we will check it ourselves
ignoreExpiration: true,
};
// debug info
jrdebug.cdebugObj("misc", strategyOptions, "setupPassportJwt strategyOptions");
passport.use(jwtStrategyName, new Strategy(
strategyOptions,
async (payload, done) => {
if (tokenValidationFunction) {
// validate the token
const jrResult = JrResult.makeNew();
tokenValidationFunction(jrResult, payload);
if (jrResult.isError()) {
const errorstr = "Error with authorization token. " + jrResult.getErrorsAsString();
return done(errorstr);
}
}
// get the user payload from the token; we make a shallow copy for the userProfile we are going to return so that we don't get circular reference when we add token data
const userProfile = jrhMisc.shallowCopy(payload.user);
if (!userProfile) {
const errorstr = "Error decoding user data from access token";
return done(errorstr);
}
// BUT we'd really like to pass on some extra token info.. so we add full token to user profile object
userProfile.token = payload;
// Add some stuff to the auth token like date of the token use?
this.decorateAuthLoginToken(userProfile.token);
// return success
return done(null, userProfile);
},
));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupPassportStrategyFacebook() {
// see http://www.passportjs.org/packages/passport-facebook/
const Strategy = passportFacebook.Strategy;
const strategyOptions = {
clientID: this.getConfigVal(appdef.DefConfigKeyPassportFacebookAppId),
clientSecret: this.getConfigVal(appdef.DefConfigKeyPassportFacebookAppSecret),
callbackURL: this.calcAbsoluteSiteUrlPreferHttps("/login/facebook/auth"),
passReqToCallback: true,
};
// debug info
jrdebug.cdebugObj("misc", strategyOptions, "setupPassportStrategyFacebook options");
passport.use(new Strategy(
strategyOptions,
async (req, token, tokenSecret, profile, done) => {
jrdebug.cdebugObj("misc", token, "facebook token");
jrdebug.cdebugObj("misc", tokenSecret, "facebook tokenSecret");
jrdebug.cdebugObj("misc", profile, "facebook profile");
// get user associated with this facebook profile, OR create one, etc.
const bridgedLoginObj = {
provider: profile.provider,
providerUserId: profile.id,
extraData: {
realName: profile.displayName,
},
};
// make or connect account to bridge
return await this.lookupOrCreateBridgedLoginForPassport(req, bridgedLoginObj, done);
},
));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupPassportStrategyTwitter() {
// see http://www.passportjs.org/packages/passport-twitter/
const Strategy = passportTwitter.Strategy;
const strategyOptions = {
consumerKey: this.getConfigVal(appdef.DefConfigKeyPassportTwitterConsumerKey),
consumerSecret: this.getConfigVal(appdef.DefConfigKeyPassportTwitterConsumerSecret),
callbackURL: this.calcAbsoluteSiteUrlPreferHttps("/login/twitter/auth"),
passReqToCallback: true,
};
// debug info
jrdebug.cdebugObj("misc", strategyOptions, "setupPassportStrategyTwitter options");
passport.use(new Strategy(
strategyOptions,
async (req, token, tokenSecret, profile, done) => {
jrdebug.cdebugObj("misc", token, "twitter token");
jrdebug.cdebugObj("misc", tokenSecret, "twitter tokenSecret");
jrdebug.cdebugObj("misc", profile, "twitter profile");
// get user associated with this twitter profile, OR create one, etc.
const bridgedLoginObj = {
provider: profile.provider,
providerUserId: profile.id,
extraData: {
realName: profile.displayName,
},
};
// make or connect account to bridge
return await this.lookupOrCreateBridgedLoginForPassport(req, bridgedLoginObj, done);
},
));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupPassportStrategyGoogle() {
// see http://www.passportjs.org/packages/passport-google-oauth20/
const Strategy = passportGoogle.Strategy;
const strategyOptions = {
clientID: this.getConfigVal(appdef.DefConfigKeyPassportGoogleClientId),
clientSecret: this.getConfigVal(appdef.DefConfigKeyPassportGoogleClientSecret),
callbackURL: this.calcAbsoluteSiteUrlPreferHttps("/login/google/auth"),
passReqToCallback: true,
};
// debug info
jrdebug.cdebugObj("misc", strategyOptions, "setupPassportStrategyTwitter options");
passport.use(new Strategy(
strategyOptions,
async (req, token, tokenSecret, profile, done) => {
jrdebug.cdebugObj("misc", token, "google token");
jrdebug.cdebugObj("misc", tokenSecret, "google tokenSecret");
jrdebug.cdebugObj("misc", profile, "google profile");
// get user associated with this profile, OR create one, etc.
const bridgedLoginObj = {
provider: profile.provider,
providerUserId: profile.id,
extraData: {
realName: profile.displayName,
},
};
// make or connect account to bridge
return await this.lookupOrCreateBridgedLoginForPassport(req, bridgedLoginObj, done);
},
));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// bridge helper for facebook, twitter, etc.
async lookupOrCreateBridgedLoginForPassport(req, bridgedLoginObj, done) {
// ersatz jrContext
const jrContext = JrContext.makeNew(req); // NOTE NOTE THE NORMAL JrContext.makeNew(req, res, next)
// lookup bridged user if this already exists, or create user (or proxy user) for the bridge; if user could not be created, it's an error
const user = await this.LoginModel.processBridgedLoginGetOrCreateUserOrProxy(jrContext, bridgedLoginObj);
// add jrResult to session (error OR success), in case we did extra stuff and info to show the user
if (!jrContext.isResultEmpty()) {
jrContext.addToThisSession();
}
// otherwise log in the user -- either with a REAL user account, OR if user is a just a namless proxy for the bridged login, with that
let userProfile;
if (user) {
userProfile = user.getMinimalPassportProfile();
// ATTN: NOTE - that this may be a proxy user not yet saved to database..
if (user.isRealObjectInDatabase()) {
// add virtual token to passport userProfile?
userProfile.token = this.makeVirtualLoginToken("bridged." + bridgedLoginObj.provider);
// update login date and save it
await user.updateLastLoginDate(jrContext, true);
// set cached user so we don't have to reload them?
this.setCachedLoggedInUserManually(jrContext, user);
}
} else {
userProfile = null;
}
// return calling done of the passport strategy
return done(null, userProfile);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
createExpressServersAndListen() {
// create server
// see https://timonweb.com/posts/running-expressjs-server-over-https/
if (this.getOptionHttps()) {
// https server
const options = {
key: fs.readFileSync(this.getOptionHttpsKey()),
cert: fs.readFileSync(this.getOptionHttpsCert()),
};
const port = this.getOptionHttpsPort();
this.serverHttps = this.createOneExpressServerAndListen(true, port, options);
}
if (this.getOptionHttp()) {
// http server
const options = {};
const port = this.getOptionHttpPort();
this.serverHttp = this.createOneExpressServerAndListen(false, port, options);
}
}
createOneExpressServerAndListen(flagHttps, port, options) {
// create an http or https server and listen
let expressServer;
const normalizedPort = jrhExpress.normalizePort(port);
if (flagHttps) {
expressServer = https.createServer(options, this.expressApp);
} else {
expressServer = http.createServer(options, this.expressApp);
}
// start listening
const listener = expressServer.listen(normalizedPort);
// add event handlers (after server is listening)
// expressServer.on("error", (...args) => { this.onErrorEs(listener, expressServer, flagHttps, ...args); });
expressServer.on("error", async (...args) => { await this.onErrorEs(listener, expressServer, flagHttps, ...args); });
expressServer.on("listening", (...args) => { this.onListeningEs(listener, expressServer, flagHttps, ...args); });
return expressServer;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// the core minimal passport function that the below functions rely on ultimately
getLoggedInPassportUsr(jrContext) {
// this should not be called until we check for auth token log in
return jrhExpress.getReqPassportUsr(jrContext);
}
getLoggedInPassportUsrToken(jrContext) {
const passportUsr = this.getLoggedInPassportUsr(jrContext);
if (passportUsr && passportUsr.token) {
return passportUsr.token;
}
return undefined;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async lookupLoggedInUser(jrContext) {
// get the userid in the session cookie, OR referenced by valid auth header JWT token
// and use it to look up the logged in user -- and LOAD the user data AND cache it
// ATTN: TODO return errors on token error or user lookup error
// first check if we've CACHED this info in the req
let user = this.getCachedLoggedInUser(jrContext);
if (user) {
return user;
}
// not cached, fetch it
// new, first grab any auth token user and log them into session
// ATTN: NOTE this will actually overwrite any existing info found in session if auth token found
await this.autoLoginUserViaAuthHeaderToken(jrContext);
if (jrContext.isError()) {
// error getting user from auth header, means NO user logged in
user = null;
} else {
// get the userid from session cookie -- note that we don't yet know if this user has been banned or still exists, etc.
const userId = this.getUntrustedLoggedInUserIdFromSession(jrContext);
if (!userId) {
user = null;
} else {
// now we get the real user
user = await this.UserModel.mFindOneById(userId);
// ATTN: TODO: this is where we could check if they were banned, deleted, etc.
}
}
// update last access occasionally -- this is as good a place as any
if (user) {
// occasionally update (and save) user access date
await user.updateLastAccessDateOccasionally(jrContext);
}
// cache it
this.setCachedLoggedInUser(jrContext, user);
// return it
return user;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getLoggedInPassportUsrField(jrContext, providerField) {
const PassportUsr = this.getLoggedInPassportUsr(jrContext);
if (!PassportUsr) {
return undefined;
}
if (providerField === "localUserId") {
return PassportUsr.userId;
}
if (providerField === "localLoginId") {
return PassportUsr.loginId;
}
throw (new Error("Unknown providerField (" + providerField + ") requested in getLoggedInPassportUsrField"));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async getSessionedBridgedLogin(jrContext) {
// first check if we've CACHED this info in the req
let login = this.getCachedBridgeLogin(jrContext);
if (login !== undefined) {
return login;
}
// not cached
const loginId = this.getSessionedBridgedLoginId(jrContext);
if (!loginId) {
login = null;
} else {
login = await this.LoginModel.mFindOneById(loginId);
}
// cache it
this.setCachedBridgeLogin(jrContext, login);
// return it
return login;
}
// helper function to get logged in local Login model id
getSessionedBridgedLoginId(jrContext) {
return this.getLoggedInPassportUsrField(jrContext, "localLoginId");
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// just shortcuts to verifcationModel statics
async getLastSessionedVerification(jrContext) {
// The idea here is that:
// 1. A user may hit the registration page (for example), providing a plaintext verification code (to confirm their newly provided email address)
// 2. At which point, rather than CONSUMING the verification code, we want to ask them for additional information before we create their account
// 2b. [To see example of this option try registering an account but not providing a password -- you will be asked for one after you confirm your email]
// 3. This creates a dilema, as we have tested the verification code and found it valid, but we need to EITHER remember it in session (makes most sense?)
// 4. Or pass it along with the follow up form...
// first check if we've CACHED this info in the req
let verification = this.getCachedVerification(jrContext);
if (verification !== undefined) {
return verification;
}
// not cached
const verificationId = this.getLastSessionedVerificationId(jrContext);
if (!verificationId) {
verification = null;
} else {
verification = await this.VerificationModel.mFindOneById(verificationId);
if (verification) {
// add back the plaintext unique code that we saved in session into the object
// in this way, we make it possible to re-process this verification code, and find it in the database, as if user was providing it
// ATTN:TODO - this seems wasteful; obviously if we have it in session we shouldnt need to "find" it again.
verification.setUniqueCode(this.getLastSessionedVerificationCodePlaintext(jrContext));
}
}
// cache it
this.setCachedVerification(jrContext, verification);
// return it
return verification;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// helper function to get last verification id
// see VerificationModel code for where this is set
// see VerificationModel code for where this is set
getCachedVerification(jrContext) {
return jrContext.arCachedLastVerification;
}
setCachedVerification(jrContext, verification) {
jrContext.arCachedLastVerification = verification;
}
clearCachedVerification(jrContext) {
delete jrContext.arCachedLastVerification;
}
getCachedBridgeLogin(jrContext) {
return jrContext.arCachedLogin;
}
setCachedBridgeLogin(jrContext, bridgedLogin) {
jrContext.arCachedLogin = bridgedLogin;
}
clearCachedBridgeLogin(jrContext) {
delete jrContext.arCachedLogin;
}
setCachedLoggedInUser(jrContext, user) {
// note that user COULD be null/undefined
// cache it for subsequent call
jrContext.arCachedUser = user;
}
setCachedLoggedInUserManually(jrContext, user) {
// this is called by login helpers so we can avoid re-looking up users
// ATTN: but we need to be careful that we check them for banning, etc.
if (user && user.isRealObjectInDatabase()) {
this.setCachedLoggedInUser(jrContext, user);
} else {
// ATTN:TODO clear it?
}
}
getCachedLoggedInUser(jrContext) {
return jrContext.arCachedUser;
}
clearCachedLoggedInUser(jrContext) {
delete jrContext.arCachedUser;
}
getCachedFlagAuthHeaderChecked(jrContext) {
return jrContext.arCachedFlagAuthHeaderChecked;
}
setCachedFlagAuthHeaderChecked(jrContext, val) {
jrContext.arCachedFlagAuthHeaderChecked = val;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setLastSessionedVerificationId(jrContext, verifcationId) {
jrContext.req.session.arLastVerificationId = verifcationId;
}
getLastSessionedVerificationId(jrContext) {
return jrContext.req.session.arLastVerificationId;
}
clearLastSessionedVerificationId(jrContext) {
delete jrContext.req.session.arLastVerificationId;
}
setLastSessionedVerificationCodePlaintext(jrContext, verificationCodePlaintext) {
jrContext.req.session.arLastVerificationCodePlaintext = verificationCodePlaintext;
}
getLastSessionedVerificationCodePlaintext(jrContext) {
return jrContext.req.session.arLastVerificationCodePlaintext;
}
clearLastSessionedVerificationCodePlaintext(jrContext) {
delete jrContext.req.session.arLastVerificationCodePlaintext;
}
setLastSessionedVerificationDate(jrContext, verificationDate) {
jrContext.req.session.arLastVerificationDate = verificationDate;
}
getLastSessionedVerificationDate(jrContext) {
return jrContext.req.session.arLastVerificationDate;
}
clearLastSessionedVerificationDate(jrContext) {
delete jrContext.req.session.arLastVerificationDate;
}
clearLastSessionVerificationAll(jrContext) {
this.clearLastSessionedVerificationId(jrContext);
this.clearLastSessionedVerificationCodePlaintext(jrContext);
this.clearLastSessionedVerificationDate(jrContext);
/*
jrhExpress.forgetSessionVar(jrContext, "lastVerificationId");
jrhExpress.forgetSessionVar(jrContext, "lastVerificationCodePlaintext");
jrhExpress.forgetSessionVar(jrContext, "lastVerificationDate");
*/
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setLastSessionedDivertedUrl(jrContext, divertedUrl) {
jrContext.req.session.arLastDivertedUrl = divertedUrl;
}
getLastSessionedDivertedUrl(jrContext) {
return jrContext.req.session.arLastDivertedUrl;
}
clearLastSessionedDivertedUrl(jrContext) {
delete jrContext.req.session.arLastDivertedUrl;
}
setLastSessionedCsrfSecret(jrContext, csrfSecret) {
jrContext.req.session.arLastCsrfSecret = csrfSecret;
}
getLastSessionedCsrfSecret(jrContext) {
return jrContext.req.session.arLastCsrfSecret;
}
clearLastSessionedCsrfSecret(jrContext) {
delete jrContext.req.session.arLastCsrfSecret;
// in this case, force save of session right away, so that if app crashes, it's still consumed
jrContext.req.session.save();
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
clearSessionDataForUserOnLogout(jrContext, flagClearAll) {
// logout the user from passport
jrContext.req.logout();
if (flagClearAll) {
// forcefully forget EVERYTHING?
jrContext.req.session.destroy();
} else {
// ignore any previous login diversions
this.clearLastSessionedDivertedUrl(jrContext);
// forget remembered verification codes, etc.
this.clearLastSessionVerificationAll(jrContext);
// csrf?
this.clearLastSessionedCsrfSecret(jrContext);
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// ATTN: For another session variable we use see JrResult.setSessionedJrResult
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// helper function to get logged in local User model id
// ATTN: this function is called quite a bit from different places, and I'm not sure that is smart.. It's not a trustworthy value since it just session saved user id.. we haven't yet fetched the user to make sure its still good (not banned, etc.)
getUntrustedLoggedInUserIdFromSession(jrContext) {
return this.getLoggedInPassportUsrField(jrContext, "localUserId");
}
clearSessionedUser(jrContext) {
if (jrContext.req) {
delete jrContext.req.user;
if (jrContext.req.session && jrContext.req.session.passport) {
delete jrContext.req.session.passport.user;
}
}
// caches
this.clearCachedVerification(jrContext);
this.clearCachedBridgeLogin(jrContext);
this.clearCachedLoggedInUser(jrContext);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
isSessionLoggedIn(jrContext) {
// just return true if they are logged in user
// NOTE: this will not be true for a user who has provided an AUTH token with non session login
if (this.getLoggedInPassportUsr(jrContext)) {
return true;
}
return false;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// jwt token access for api access; credential passed in via access token
// 3 ways to call depending on which data you want
// see also setupPassportJwt() where the jwt stuff is set up
// NOTE that these nonSession functions do NOT log in the user or set req.user -- they are ways to test the authentication WITHOUT doing so
async asyncPassportManualNonSessionAuthenticateFromTokenInRequestGetMinimalPassportUsrData(jrContext, next, requiredTokenType) {
// force passport authentication from request, looking for jwt token
// generic call to passport, with jwt type
// note we ask the function to not lookup full user since we dont need it (last false parameter)
const passportUsrData = await this.asyncPassportManualNonSessionAuthenticateGetMinimalPassportUsrData(jrContext, "jwtManualQueryOrPostBody", "using jwt", next);
if (!jrContext.isError()) {
// let's check token validity (expiration, etc.); this may push an error into jrResult
// ATTN: TODO - This does NOT check user.apiCode, which can be used to revoke api keys
// BUT this should be done after we load the users full profile, which the CALLER of our function does
// I believe this function is never called EXCEPT inside that one, so it will always be performed
// jrlog.debugObj(passportUsrData.token, "access token pre validate.");
this.validateSecureToken(jrContext.result, passportUsrData.token, requiredTokenType);
} else {
// change error code from generic to token specific or add?
jrContext.pushErrorOnTop("Invalid access token");
jrContext.setExtraData("tokenError", true);
}
// return fale or null
return passportUsrData;
}
async asyncPassportManualNonSessionAuthenticateFromTokenInRequestGetPassportProfileAndUser(jrContext, next, requiredTokenType) {
// force passport authentication from request, looking for jwt token
const userMinimalProfile = await this.asyncPassportManualNonSessionAuthenticateFromTokenInRequestGetMinimalPassportUsrData(jrContext, next, requiredTokenType);
if (jrContext.isError()) {
return [userMinimalProfile, null];
}
// load full profile from minimal -- this should check apicode because of the final true parameter for flagCheckAccessCode
// in this way we will record an error if the token has been revoked (apicode does not match)
const user = await this.loadUserFromMinimalPassportUsrData(jrContext, userMinimalProfile, true);
return [userMinimalProfile, user];
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async loadUserFromMinimalPassportUsrData(jrContext, userMinimalPassportProfile, flagCheckAccessCode) {
// load full user model given a minimal (passport) profile with just the id field
if (!userMinimalPassportProfile) {
jrContext.pushError("Invalid login; error code 2.");
return null;
}
const userId = userMinimalPassportProfile.userId;
if (!userId) {
jrContext.pushError("Invalid login; error code 3.");
return null;
}
const user = await this.UserModel.mFindOneById(userId);
if (!user) {
jrContext.pushError("Invalid login; error code 4 (user not found in database).");
}
if (flagCheckAccessCode) {
if (!userMinimalPassportProfile.token) {
jrContext.pushError("Invalid login; error code 5b (missing accesstoken data).");
}
if (!user.verifyApiCode(userMinimalPassportProfile.token.apiCode)) {
jrContext.pushError("Invalid login; error code 5 (found user and access token is valid but apiCode revision has been revoked).");
}
}
return user;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
calcAbsoluteSiteUrlPreferHttps(relativePath) {
// build an absolute url
let protocol;
let port;
// get protocol and port (unless default port)
if (this.getOptionHttps()) {
// ok we are running an https server
protocol = "https";
port = this.getOptionHttpsPort();
if (String(port) === "443") {
port = "";
}
} else {
protocol = "http";
port = this.getOptionHttpPort();
if (String(port) === "80") {
port = "";
}
}
// add full protocol
let url = protocol + "://" + this.getOptionSiteDomain() + ":" + port;
// add relative path
if (relativePath !== "") {
if (relativePath[0] !== "/") {
url += "/";
}
url += relativePath;
}
return url;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// wrappers around passport.authenticate,
// which convert(wrap) the non-promise non-async function passport.authenticate into an sync function that uses a promise
// and do other stuff
// this will end up calling a passport STRATEGY above
// @param errorCallback is a function that takes (req,res,jrinfo) for custom error handling,
// where jrinfo is the JrResult style error message created from the passport error;
// normally you would use this to RE-RENDER a form from a post submission, overriding the
// default behavior to redirect to the login page with flash error message
async asyncRoutePassportAuthenticate(jrContext, provider, providerNiceLabel, flagRememberUserInSessionCookie, flagAutoRedirectSuccess, flagAutoRedirectError, flagAddResultMessageToSession) {
// "manual" authenticate via passport (as opposed to middleware auto); allows us to get richer info about error, and better decide what to do
// ATTN: note that res may be null; if so make sure flagAutoRedirectSuccess and flagAutoRedirectError are false
assert(jrContext.res || (!flagAutoRedirectSuccess && !flagAutoRedirectError));
// but before we authenticate and log in the user lets see if they are already "logged in" using a Login object
const previousLoginId = this.getSessionedBridgedLoginId(jrContext);
const thisArserver = this;
// options
const authOptions = {};
const loginOptions = {};
if (!flagRememberUserInSessionCookie) {
authOptions.session = false;
loginOptions.session = false;
}
// run and wait for passport.authenticate async
const userPassport = await jrhExpress.asyncPassportAuthenticate(jrContext, authOptions, provider, providerNiceLabel);
// now actually log them in to the req.user etc.
if (!jrContext.isError()) {
await jrhExpress.asyncPassportReqLogin(jrContext, loginOptions, userPassport, "Error while authenticating user " + providerNiceLabel);
}
if (!jrContext.isError()) {
// success
try {
// userId we JUST signed in as -- NOTE: this could be null if its a local bridged login short of a full user account
const newlyLoggedInUserId = thisArserver.getUntrustedLoggedInUserIdFromSession(jrContext);
// announce success
if (flagRememberUserInSessionCookie) {
if (newlyLoggedInUserId) {
jrContext.pushSuccess("You have successfully logged in " + providerNiceLabel + ".");
} else {
jrContext.pushSuccess("You have successfully connected " + providerNiceLabel + ".");
}
} else {
// some other message if we don't want to remember them but they have logged in for THIS REQUEST ONLY
jrContext.pushSuccess("You have successfully authenticated " + providerNiceLabel + ".");
}
// and NOW if they were previously sessioned with a pre-account Login object, we can connect that to this account
if (newlyLoggedInUserId && previousLoginId) {
// try to connect
await this.LoginModel.connectUserToLogin(jrContext, newlyLoggedInUserId, previousLoginId, false);
}
// add message to session?
if (flagAddResultMessageToSession) {
jrContext.addToThisSession(true);
}
if (flagAutoRedirectSuccess) {
// do some redirections..
// check if they were waiting to go to another page
if (newlyLoggedInUserId && thisArserver.userLogsInCheckDiverted(jrContext)) {
return;
}
// new full account connected?
if (newlyLoggedInUserId) {
jrContext.res.redirect("/profile");
return;
}
// no user account made yet, default send them to full account fill int
jrContext.res.redirect("/register");
}
} catch (err) {
// unexpected error
jrContext.pushError(err.message);
}
}
// errors? if so return or redirect
if (jrContext.isError()) {
if (flagAutoRedirectError) {
// if caller wants us to handle error case of redirect we will
// save error to session (flash) and redirect to login
jrContext.addToThisSession();
jrContext.res.redirect("/login");
}
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// wrappers around passport.authenticate,
// which convert(wrap) the non-promise non-async function passport.authenticate into an sync function that uses a promise
// and do other stuff
// generic passport route login helper function, invoked from login routes
// this will end up calling a passport STRATEGY
async asyncPassportManualNonSessionAuthenticateGetMinimalPassportUsrData(jrContext, provider, providerNiceLabel) {
// "manual" authenticate via passport (as opposed to middleware auto); allows us to get richer info about error, and better decide what to do
// return userPassport
// run and wait for passport.authenticate async
const userPassport = await jrhExpress.asyncPassportAuthenticate(jrContext, { session: false }, provider, providerNiceLabel);
// error?
if (jrContext.isError()) {
jrdebug.cdebug("misc", "In asyncPassportManualNonSessionAuthenticateGetMinimalPassportUsrData 2 error from userPassport :" + jrContext.getErrorsAsString());
return null;
}
// return it
return userPassport;
}
async asyncPassportManualNonSessionAuthenticateGetUser(jrContext, provider, providerNiceLabel) {
// "manual" authenticate via passport (as opposed to middleware auto); allows us to get richer info about error, and better decide what to do
// return user
// get minimal userPassport data
const userPassport = await this.asyncPassportManualNonSessionAuthenticateGetMinimalPassportUsrData(jrContext, provider, providerNiceLabel);
if (!userPassport) {
// error will have already been set
return null;
}
let user;
try {
// now get user
user = await this.loadUserFromMinimalPassportUsrData(jrContext, userPassport, false);
if (!user) {
jrContext.pushError("Error authenticating " + providerNiceLabel + ": could not locate user in database.");
return null;
}
} catch (err) {
// unexpected error
jrContext.pushError(err.message);
return null;
}
// return it
return user;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async asyncManuallyLoginUserToSessionThroughPassport(jrContext, user, loginMethod) {
// ATTN: this may be called via onetime email login, etc
const userProfile = user.getMinimalPassportProfile();
// set virtual token of profile so observers can see HOW (and when) the user logged in
userProfile.token = this.makeVirtualLoginToken(loginMethod);
// can be used to disable session saving, etc.
const loginOptions = {};
try {
// run login using async function wrapper
await jrhExpress.asyncPassportReqLogin(jrContext, loginOptions, userProfile, "Authentication login error");
if (!jrContext.isError()) {
// update login date and save it
await user.updateLastLoginDate(jrContext, true);
// set cached user so we don't have to reload them
this.setCachedLoggedInUserManually(jrContext, user);
}
} catch (err) {
// unexpected error
jrContext.pushError(err.message);
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
setupViewTemplateExtras() {
// handlebar stuff
// create general purpose handlebar helper functions we can call
jrhHandlebars.setupJrHandlebarHelpers();
// parse and make available partials from files
jrhHandlebars.loadPartialFiles(this.getSourceSubDir("views/partials"), "");
}
getViewPath() {
// return absolute path of view files
// this is used by crud aid class so it knows how to check for existence of certain view files
return this.getSourceSubDir("views");
}
getViewExt() {
// return extension of view files with . prefix
// this is used by crud aid class so it knows how to check for existence of certain view files
return ".hbs";
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async sendMail(jrContext, mailobj) {
// just pass on request to sendAid module
this.sendAid.sendMail(jrContext, mailobj);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
cacheMiscOptions() {
// cache some options for quicker access
// note that we might have to call this whenever options change
this.gravatarOptions = this.getOptionsGravatar();
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// Event listener for HTTP server "error" event.
async onErrorEs(listener, expressServer, flagHttps, error) {
// called not on 404 errors but other internal errors?
let msg;
if (error.syscall !== "listen") {
throw error;
}
// dummy jrContext since we don't know any other way to get req/res
const jrContext = JrContext.makeNew();
// ATTN: not clear why this uses different method than OnListeningEs to get port info, etc.
const addr = listener.address();
let bind;
if (addr === null) {
msg = "Could not bind server listener, got null return from listener.address paramater. Is server already running (in debugger) ?";
jrdebug.debug(msg);
await this.logr(jrContext, appdef.DefLogTypeErrorServer, msg);
process.exit(1);
} else if (typeof addr === "string") {
bind = "pipe " + addr;
} else if (addr.port) {
bind = "port " + addr.port;
} else {
msg = "Could not bind server listener, the listener.address paramater was not understood: " + addr;
jrdebug.debug(msg);
await this.logr(jrContext, appdef.DefLogTypeErrorServer, msg);
process.exit(1);
}
// handle specific listen errors with friendly messages
switch (error.code) {
case "EACCES":
await this.logr(jrContext, appdef.DefLogTypeErrorServer, bind + " requires elevated privileges");
process.exit(1);
break;
case "EADDRINUSE":
await this.logr(jrContext, appdef.DefLogTypeErrorServer, bind + " is already in use");
process.exit(1);
break;
default:
throw error;
}
}
// Event listener for HTTP server "listening" event.
onListeningEs(listener, expressServer, flagHttps) {
const server = expressServer;
const addr = server.address();
const bind = (typeof addr === "string")
? "pipe " + addr
: "port " + addr.port;
// show some info
const serverTypestr = flagHttps ? "https" : "http";
jrdebug.debug("Server (" + serverTypestr + ") started, listening on " + bind);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async runServer() {
// run the server -- this is called AFTER other setup stuff
// we need to shutdown
this.setNeedsShutdown(true);
// setup express stuff
this.setupExpress();
// postsetup used to happen here
// setup mail/messaging helper
await this.setupSendAid();
// other model stuff
await this.setupAcl();
// rate limiter
await this.setupRateLimiters();
// now make the express servers (http AND/OR https)
this.createExpressServersAndListen();
// now create a log entry about the server starting up
await this.logStartup();
// check at the end for any failed requires due to circular reference
// jrequire.checkCircularRequireFailures();
// done setup
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async setupSendAid() {
const mailTransportConfigObj = {
host: this.getConfigVal(appdef.DefConfigKeyMailerHost),
port: this.getConfigVal(appdef.DefConfigKeyMailerPort),
secure: this.getConfigVal(appdef.DefConfigKeyMailerSecure),
auth: {
user: this.getConfigVal(appdef.DefConfigKeyMailerUsername),
pass: this.getConfigVal(appdef.DefConfigKeyMailerPassword),
},
};
//
const defaultFrom = this.getConfigVal(appdef.DefConfigKeyMailerFrom);
const flagDebugMode = this.getConfigValDefault(appdef.DefConfigKeyMailerDebug, false);
//
await this.sendAid.setupMailer(mailTransportConfigObj, defaultFrom, flagDebugMode);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async startUp(flagRunServer) {
// start profiling?
this.engageProfilerIfAppropriate();
// create database structure and connect
let bretv = await this.createAndConnectToDatabase();
if (bretv) {
// set up initial (admin etc) users if needed
bretv = await this.setupAid.createDefaultUsers();
}
if (bretv && flagRunServer) {
// actually run the server and start listening
bretv = await this.runServer();
}
/*
if (!bretv) {
throw new Error("Failure to startup.");
}
*/
return bretv;
}
async createAndConnectToDatabase() {
// setup database stuff (create and connect to models -- callable whether db is already created or not)
let bretv = false;
// set mongo timeout.. should be less than mocha test limit
const DefConnectTimeoutMs = 5000;
// we need to shutdown
this.setNeedsShutdown(true);
const mongooseOptions = {
useNewUrlParser: true,
// see https://github.com/Automattic/mongoose/issues/8156
useUnifiedTopology: true,
useCreateIndex: true,
// timeouts to throw error during tests
connectTimeoutMS: DefConnectTimeoutMs,
serverSelectionTimeoutMS: DefConnectTimeoutMs,
};
// ATTN: 5/12/20 trying this to figure out why we can't catch mongoose exceptions on save
// see https://stackoverflow.com/questions/31396021/mongoose-save-using-native-promise-how-to-catch-errors
mongoose.Promise = Promise;
try {
// connect to db
const mongoUrl = this.calcFullDbUrl();
jrdebug.cdebug("misc", "Connecting to mongoose-mongodb: " + mongoUrl);
// try to connect
// await mongoose.connect(mongoUrl, mongooseOptions);
await mongoose.connect(mongoUrl, mongooseOptions, (error) => {
// console.log("IN mongoose connect error.");
});
// alternate connect with setTimeout?
/*
setTimeout(async () => {
await mongoose.connect(mongoUrl, mongooseOptions);
}, DefConnectTimeoutMs);
*/
// check if connected
if (!this.isConnectedToDatabase()) {
jrdebug.debug("Failure while trying to connect to mongoose database at " + mongoUrl + " (connection timed out?).");
return false;
}
// set up schemas for all models
await jrhMisc.asyncAwaitForEachObjectKeyFunctionCall(this.models, async (key, val) => {
await this.setupModelSchema(mongoose, val);
});
// set some options for mongoose/mongodb
// to skip some deprecation warnigns; see https://github.com/Automattic/mongoose/issues/6880 and https://mongoosejs.com/docs/deprecations.html
await mongoose.set("useFindAndModify", false);
// deprecation warnings triggered by acl module
mongoose.set("useCreateIndex", true);
// success return value -- if we got this far it"s a success; drop down
bretv = true;
} catch (err) {
jrdebug.debug("Exception while trying to setup database:");
jrdebug.debug(err);
bretv = false;
}
return bretv;
}
isConnectedToDatabase() {
// see https://mongoosejs.com/docs/api/connection.html#connection_Connection-readyState
const readyState = mongoose.connection.readyState;
return (readyState === 1);
}
async setupModelSchema(mongooser, modelClass) {
// just ask the base model class to do the work
await modelClass.setupModelSchema(mongooser);
}
async shutDown() {
// close down the server
// do we need to shutdown
if (!this.getNeedsShutdown()) {
// already called
return;
}
// clear flag
this.setNeedsShutdown(false);
jrdebug.debug("Server shutting down..");
// now create a log entry about the server starting up
await this.logShutdown();
// close the listeners
if (this.serverHttps) {
this.serverHttps.close(() => {
});
this.serverHttps = undefined;
}
if (this.serverHttp) {
this.serverHttp.close(() => {
});
this.serverHttp = undefined;
}
// now disconnect the database connections
await this.dbDisconnect();
jrdebug.debug("Server shutdown complete.");
// shutdown profiler?
this.disEngageProfilerIfAppropriate();
}
async dbDisconnect() {
// disconnect from mongoose/mongodb
if (this.isConnectedToDatabase()) {
// connected so shutdown
jrdebug.debug("Closing mongoose-mongodb connection.");
await mongoose.disconnect();
await mongoose.connection.close();
}
// ATTN: took several hours to track this down why mocha tests could not shut down server
// session store needs explicit close to exit gracefully
if (this.rememberedSessionStore) {
await this.rememberedSessionStore.close();
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async setupAcl() {
await this.aclAid.setupAclPermissions();
}
//---------------------------------------------------------------------------
/**
* Set up and configure the rate limiters that we use
*
* ATTN: TODO: This should be modified to support configuration options for the parameters
*
* @memberof AppRoomServer
*/
async setupRateLimiters() {
//
jrhRateLimiter.setupRateLimiter(appdef.DefRateLimiterBasic, { points: 5, duration: 10, blockDuration: 10 });
jrhRateLimiter.setupRateLimiter(appdef.DefRateLimiterApi, { points: 5, duration: 30, blockDuration: 30 });
jrhRateLimiter.setupRateLimiter(appdef.DefRateLimiterEmergencyAlert, { points: 5, duration: 60, blockDuration: 30 });
jrhRateLimiter.setupRateLimiter(appdef.DefRateLimiterTest, { points: 5, duration: 2, blockDuration: 2 });
}
getRateLimiterBasic() {
return jrhRateLimiter.getRateLimiter(appdef.DefRateLimiterBasic);
}
getRateLimiterApi() {
return jrhRateLimiter.getRateLimiter(appdef.DefRateLimiterApi);
}
getRateLimiterEmergencyAlert() {
return jrhRateLimiter.getRateLimiter(appdef.DefRateLimiterEmergencyAlert);
}
getRateLimiterTest() {
return jrhRateLimiter.getRateLimiter(appdef.DefRateLimiterTest);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async logStartup() {
// log the startup event
let msg = "Starting up on " + jrhMisc.getNiceNowString() + ".\n";
msg += " " + this.getLoggingAnnouncement() + ".\n";
msg += " Logging to " + this.getLogFileBaseName() + ".log.\n";
msg += " Running on server " + this.getServerIp() + ".\n";
if (this.isDevelopmentMode()) {
msg += " Development mode enabled.\n";
}
// dummy jrContext since we don't know any other way to get req/res
const jrContext = JrContext.makeNew();
await this.logr(jrContext, appdef.DefLogTypeInfoServer, msg);
/*
const debugTagString = jrdebug.getDebugTagEnabledListAsNiceString();
if (debugTagString) {
await this.logr(jrContext, appdef.DefLogTypeDebug, "Debug log tags: " + debugTagString);
}
*/
}
async logShutdown() {
// log the shutdown event
const msg = util.format("Shutting down server on %s.", jrhMisc.getNiceNowString());
// dummy jrContext since we don't know any other way to get req/res
const jrContext = JrContext.makeNew();
await this.logr(jrContext, appdef.DefLogTypeInfoServer, msg);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async logr(jrContext, type, message, extraData, user) {
// create log obj
let userid, ip;
const req = jrContext.req;
if (!req) {
ip = undefined;
} else {
ip = jrhText.cleanIp(req.ip);
}
if (user) {
userid = user.id;
} else if (req && req.user) {
userid = this.getUntrustedLoggedInUserIdFromSession(jrContext);
} else {
userid = undefined;
}
// make mergeData -- these are fields that have actual dedicated explicit log database table properies
const mergeData = {
userid,
ip,
};
// hand off to more generic function
await this.internalLogm(jrContext, type, message, extraData, mergeData);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async internalLogm(jrContext, type, message, extraData, mergeData) {
// we now want to hand off the job of logging this item to any registered file and/or db loggers
let flagLogToDb = true;
const flagLogToFile = true;
// extra data fixups
let extraDataPlus;
// if its an error object it doesnt handle properly to mongoose hash or log merge
if (extraData instanceof Error) {
extraDataPlus = jrhMisc.ErrorToHashableMapObject(extraData);
} else {
extraDataPlus = extraData;
}
if (!this.isConnectedToDatabase()) {
// disable this since we are not connected to database
flagLogToDb = false;
}
// save to db
if (flagLogToDb) {
const logModelClass = this.calcLoggingCategoryModelFromLogMessageType(type);
await this.logmToDbModelClass(logModelClass, type, message, extraDataPlus, mergeData);
}
// save to file
if (flagLogToFile) {
const category = this.calcLoggingCategoryFromLogMessageType(type);
jrlog.logMessage(category, type, message, extraDataPlus, mergeData);
}
// some errors we should trigger emergency alert
if (this.shouldAlertOnLogMessageType(type)) {
// trigger emegency alert
await this.emergencyAlert(jrContext, type, "Critical error logged on " + jrhMisc.getNiceNowString(), message, extraDataPlus, false);
}
}
async logmToDbModelClass(logModelClass, type, message, extraData, mergeData) {
try {
await logModelClass.createLogDbModelInstanceFromLogDataAndSave(type, message, extraData, mergeData);
// uncomment to test fallback error logging
// throw Error("logmToDbModelClass exception test.");
} catch (err) {
// error while logging to db.
// log EXCEPTION message (INCLUDES original) to file; note we may still try to log the original cleanly to file below
jrdebug.debug("Logging fatal exception to error log file:");
jrdebug.debugObj(err, "Error");
jrlog.logExceptionErrorWithMessage(appdef.DefLogCategoryError, err, type, message, extraData, mergeData);
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
calcLoggingCategoryFromLogMessageType(type) {
// we want to decide which logging category (file) to put this logg message in
// ATTN: we might replace this with something that loops through an array of prefixes associated with categories to make it easier and less hard coded
// 404s go in their own file
if (type.startsWith(appdef.DefLogTypeError404)) {
return appdef.DefLogCategoryError404;
}
if (type.startsWith(appdef.DefLogTypeError)) {
return appdef.DefLogCategoryError;
}
if (type.startsWith(appdef.DefLogTypeDebug)) {
return appdef.DefLogCategoryDebug;
}
// fallback to default
return appdef.DefLogCategory;
}
calcLoggingCategoryModelFromLogMessageType(type) {
// decide which log model class (db table) to use for this log message
// currently there is only the one
return this.LogModel;
}
shouldAlertOnLogMessageType(type) {
if (type.startsWith(appdef.DefLogTypeErrorCritical)) {
return true;
}
return false;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async aclRequireLoggedInSitePermissionRenderErrorPageOrRedirect(jrContext, permission, goalRelUrl) {
return await this.aclRequireLoggedInPermissionRenderErrorPageOrRedirect(jrContext, permission, appdef.DefAclObjectTypeSite, null, goalRelUrl);
}
async aclRequireLoggedInPermissionRenderErrorPageOrRedirect(jrContext, permission, permissionObjType, permissionObjId, goalRelUrl) {
const user = await this.lookupLoggedInUser(jrContext);
// we just need to check if the user is non-empty
if (!user) {
// user is not logged in
this.handleRequireLoginFailure(jrContext, user, goalRelUrl, null, appdef.DefRequiredLoginMessage);
return false;
}
// they are logged in, but do they have permission required
const hasPermission = await user.aclHasPermission(jrContext, permission, permissionObjType, permissionObjId);
if (!hasPermission) {
this.handleRequireLoginFailure(jrContext, user, goalRelUrl, null, "You do not have sufficient permission to accesss that page.");
return false;
}
// they are good, so forget any previously remembered login diversions
this.clearLastSessionedDivertedUrl(jrContext);
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async requireLoggedIn(jrContext) {
const user = await this.lookupLoggedInUser(jrContext);
return this.requireUserIsLoggedInRenderErrorPageOrRedirect(jrContext, user, null);
}
requireUserIsLoggedInRenderErrorPageOrRedirect(jrContext, user, goalRelUrl, failureRelUrl) {
// if user fails permission, remember the goalRelUrl in session and temporarily redirect to failureRelUrl and return false
// otherwise return true
if (!user) {
this.handleRequireLoginFailure(jrContext, user, goalRelUrl, failureRelUrl, appdef.DefRequiredLoginMessage);
return false;
}
// they are good, so forget any previously remembered login diversions
this.clearLastSessionedDivertedUrl(jrContext);
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
handleRequireLoginFailure(jrContext, user, goalRelUrl, failureRelUrl, errorMsg) {
// set failureRelUrl default
if (!failureRelUrl) {
failureRelUrl = "/login";
}
// ok this is failure, save rediret goal url
this.rememberDivertedRelUrlAndGo(jrContext, goalRelUrl, failureRelUrl, errorMsg);
}
divertToLoginPageThenBackToCurrentUrl(jrContext, emsg) {
// redirect them to login page and then back to their currently requested page
const failureRelUrl = "/login";
this.rememberDivertedRelUrlAndGo(jrContext, null, failureRelUrl, emsg);
}
rememberDivertedRelUrlAndGo(jrContext, goalRelUrl, failureRelUrl, msg) {
this.rememberDivertedRelUrl(jrContext, goalRelUrl, msg);
// now redirect
if (failureRelUrl) {
jrContext.res.redirect(failureRelUrl);
}
}
rememberDivertedRelUrl(jrContext, goalRelUrl, msg) {
// if no goal url passed, then use current request url
if (!goalRelUrl) {
goalRelUrl = jrhExpress.reqOriginalUrl(jrContext.req);
}
// remember where they were trying to go when we diverted them, so we can go BACK there after they log in
jrContext.req.session.divertedUrl = goalRelUrl;
if (msg) {
jrContext.pushError(msg);
}
jrContext.addToThisSession();
}
userLogsInCheckDiverted(jrContext) {
// check if user should be diverted to another page, for example after logging in
// return true if we divert them, meaning the caller should not do any rendering of the page, etc.
if (!jrContext.req.session || !jrContext.req.session.divertedUrl) {
return false;
}
// ok we got one
const divertedUrl = jrContext.req.session.divertedUrl;
// forget it
this.clearLastSessionedDivertedUrl(jrContext);
// now send them there!
jrContext.res.redirect(divertedUrl);
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// present user with new account create form they can submit to ACTUALLY create their new account
// this would typically be called AFTER the user has verified their email with verification model
presentNewAccountRegisterForm(jrContext, userObj, verification) {
// ATTN: is this ever called
throw (new Error("presentNewAccountRegisterForm not implemented yet."));
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
sendLoggedInUsersElsewhere(jrContext) {
// if they are logged in with a real user account already, just redirect them to their profile and return true
// otherwise return false;
const userId = this.getUntrustedLoggedInUserIdFromSession(jrContext);
if (userId) {
jrContext.res.redirect("profile");
return true;
}
return false;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// csrf helpers -- so we dont have to install as ever-present middleware
makeCsrf(jrContext) {
// in this case we pass next() function which just returns value passed to it
if (jrContext.req.csrfToken) {
// already in req, just return it
return jrContext.req.csrfToken();
}
return this.csrfInstance(jrContext.req, jrContext.res, (err) => {
if (err === undefined || err.code === "EBADCSRFTOKEN") {
// no error, or csrf bad-token error, which we dont care about since we've just been asked to make one
return jrContext.req.csrfToken();
}
// pass the error back
return err;
});
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Test the csrf
* This is more useful for re-presenting a form.
* ATTN: We had some real problems with this new function returning undefined on successful csrf when I tried to simply return jrResult from inside the callback.. I'm still not sure I understand why; it may be that on non-error it was async executed
* ATTN TODO: make sure this always works as expected
*
* @param {*} req
* @param {*} res
* @returns success jrResult if no error in csrf; otherwise a jrResult that is an error
* @memberof AppRoomServer
*/
testCsrf(jrContext) {
// let csrf throw the error to next, ONLY if there is an error, otherwise just return and dont call next
// Useful for testing csrf, make it fail
const forceFail = this.getConfigValDefault(appdef.DefConfigKeyTestingForceCsrfFail, false);
if (forceFail) {
jrContext.pushError("Forcing csrf test to return false for testing purposes; to disable see option '" + appdef.DefConfigKeyTestingForceCsrfFail + "'.");
}
const retv = this.csrfInstance(jrContext.req, jrContext.res, (err) => {
if (err) {
jrContext.pushError(err.toString());
} else {
// forget it so it can't be used twice?
if (true) {
this.clearLastSessionedCsrfSecret(jrContext);
}
}
});
// return true on success
if (!jrContext.isError()) {
return true;
}
return false;
}
testCsrfRedirectToOriginalUrl(jrContext) {
this.testCsrf(jrContext);
if (jrContext.isError()) {
// add error to session
jrContext.addToThisSession();
// redirect to same url
// jrdebug.debug("req.route.path: " + req.route.path + ", req.baseUrl: " + req.baseUrl + ", req.originalUrl: " + req.originalUrl + ", req.url:" + req.url);
jrContext.res.redirect(jrhExpress.reqOriginalUrl(jrContext.req));
return false;
}
// success
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getCsrf() {
return this.csrfInstance;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// user avatars
calcAvatarHtmlImgForUser(obj) {
let rethtml;
if (obj) {
const avatarUrl = this.calcAvatarUrlForUser(obj);
rethtml = `<img src="${avatarUrl}">`;
} else {
rethtml = " ";
}
return rethtml;
}
calcAvatarUrlForUser(obj) {
// ATTN: cache this somewhere to improve the speed of this function
const id = obj.email;
const url = gravatar.url(id, this.gravatarOptions);
return url;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
makeAlinkHtmlToAclModel(objType, objId) {
// get nice linked html text for an object from acl object types
const label = objType + " #" + objId;
let modelClass;
if (objType === "app") {
modelClass = this.appModel;
} else if (objType === "room") {
modelClass = this.roomModel;
} else if (objType === "user") {
modelClass = this.userModel;
} else if (objType === "file") {
modelClass = this.fileModel;
}
if (modelClass !== undefined) {
const alink = modelClass.getCrudUrlBase("view", objId);
const htmlText = "<a href=" + alink + ">" + label + "</a>";
return htmlText;
}
return label;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async isLoggedInUserSiteAdmin(jrContext) {
const loggedInUser = await this.lookupLoggedInUser(jrContext);
if (loggedInUser) {
const bretv = await loggedInUser.isSiteAdmin(jrContext);
return bretv;
}
return false;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async aclRequireModelAccessRenderErrorPageOrRedirect(jrContext, user, modelClass, accessTypeStr, modelId) {
// called by generic crud functions
// return FALSE if we are denying user access
// and in that case WE should redirect them or render the output
// return TRUE if we should let them continue
if (!user) {
user = await this.lookupLoggedInUser(jrContext);
if (!user) {
// ok if we denied them access and they are not logged in, make them log in -- after that they may have permission
this.divertToLoginPageThenBackToCurrentUrl(jrContext, appdef.DefRequiredLoginMessage);
return false;
}
}
// ok they are logged in, now check their permission
// conversions from model info to acl info
const permission = accessTypeStr;
const permissionObjType = modelClass.getAclName();
let permissionObjId = modelId;
if (permissionObjId === undefined) {
permissionObjId = null;
}
// check permission
const hasPermission = await user.aclHasPermission(jrContext, permission, permissionObjType, permissionObjId);
if (!hasPermission) {
// tell them they don't have access
this.renderAclAccessError(jrContext, modelClass, "You do not have permission to access this resource/page.");
return false;
}
// granted access
return true;
}
renderAclAccessError(jrContext, modelClass, errorMessage) {
jrContext.pushError(errorMessage);
// render
jrContext.res.render("acldeny", {
jrResult: jrContext.mergeSessionMessages(),
});
}
renderAclAccessErrorResult(jrContext, modelClass) {
// render
jrContext.res.render("acldeny", {
jrResult: jrContext.mergeSessionMessages(),
});
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// internal debugging info for internals admin
calcExpressRoutePathData() {
return jrhExpress.calcExpressRoutePathData(this.getExpressApp());
}
async calcWebServerInformation() {
// return info about web server
const serverInfo = {
runtime: {
started: jrhMisc.getNiceDateValString(this.procesData.started),
uptime: jrhMisc.getNiceDurationTimeMs(Date.now() - this.procesData.started),
},
setup: this.getMiscInfo(),
};
// add http and https server info
if (this.serverHttp) {
serverInfo.http = this.serverHttp.address();
}
if (this.serverHttps) {
serverInfo.https = this.serverHttps.address();
}
// middleware
serverInfo.expressMiddleware = jrhExpress.calcExpressMiddleWare(this.getExpressApp());
return serverInfo;
}
async calcDatabaseInfo() {
// return info about the database structure
const dbStrcuture = await jrhMongo.calcDatabaseInfo();
return dbStrcuture;
}
calcAclStructure() {
const aclStructure = this.aclAid.calcAclStructure();
return aclStructure;
}
calcNodeJsInfo() {
// Getting commandline
let comlinestr = process.execPath;
process.argv.forEach((val, index, array) => {
if (index !== 0) {
comlinestr += " '" + val + "'";
}
});
const nodeJsInfo = {
version: process.version,
platform: process.platform,
nodeExecPath: process.execPath,
upTime: process.upTime,
commandline: comlinestr,
memoryUsage: process.memoryUsage(),
resourceUsage: process.resourceUsage ? process.resourceUsage() : "Not supported by this version of nodeJs",
processPid: process.pid,
};
return nodeJsInfo;
}
calcDependencyInfo() {
const rawData = {
jrequire: jrequire.calcDebugInfo(),
};
return rawData;
}
calcAppInfo() {
// info about the app (version, author, etc.)
const rawData = {
about: this.getAboutInfo(),
};
return rawData;
}
calcAddonCollectionInfoPlugins() {
return this.calcAddonCollectionInfo(this.getAddonCollectionNamePlugins());
}
calcAddonCollectionInfoAppFrameworks() {
return this.calcAddonCollectionInfo(this.getAddonCollectionNameAppFrameworks());
}
calcAddonCollectionInfo(collectionName) {
let category, addonModule;
const addons = jrequire.getAllAddonCategoriesForCollectionName(collectionName);
// iterate all categories and get category data
const loadedAddonDataByCategory = {};
Object.keys(addons).forEach((categoryKey) => {
// iterate all modules in this category
category = addons[categoryKey];
Object.keys(category).forEach((name) => {
// load the module
addonModule = jrequire.requireAddonModule(collectionName, name);
// add it to the tree under this category
if (loadedAddonDataByCategory[categoryKey] === undefined) {
loadedAddonDataByCategory[categoryKey] = {};
}
loadedAddonDataByCategory[categoryKey][name] = addonModule.getInfo(this);
});
});
const configKey = this.calcCollectionConfigKey(collectionName);
const rawData = {
foundInConfiguration: this.getConfigVal(configKey),
enabledByCategory: loadedAddonDataByCategory,
};
return rawData;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Helper function to make a secure access token from a refresh token
*
* @param {Object} userPassport - minimal object with properties of the user
* @param {UserModel} user - full model object of User class
* @param {String} refreshToken - the refresh token object to use to generate access token
* @returns a token object.
*/
async makeSecureTokenAccessFromRefreshToken(user, refreshToken) {
// make an access token with SAME scope as refresh token
return await this.makeSecureTokenAccess(user, refreshToken.scope);
}
/**
* Helper function to make a Refresh token
*
* @param {Object} userPassport - minimal object with properties of the user
* @param {UserModel} user - full model object of User class
* @returns a token object
*/
async makeSecureTokenRefresh(user) {
const payload = {
type: "refresh",
scope: "api",
apiCode: await user.getApiCodeEnsureValid(),
user: user.getMinimalPassportProfile(),
};
// create secure toke
const expirationSeconds = this.getConfigVal(appdef.DefConfigKeyTokenExpirationSecsRefresh);
const secureToken = this.createSecureToken(payload, expirationSeconds);
return secureToken;
}
/**
* Helper function to make a generic secure token
*
* @param {Object} userPassport - minimal object with properties of the user
* @param {UserModel} user - full model object of User class
* @param {String} scope - the refresh token object to use to generate access token
* @returns a token object
*/
async makeSecureTokenAccess(user, scope) {
const payload = {
type: "access",
scope,
apiCode: await user.getApiCodeEnsureValid(),
user: user.getMinimalPassportProfile(),
};
// add accessId -- the idea here is for every user object in database to ahve an accessId (either sequential or random); that can be changed to invalidate all previously issues access tokens
const expirationSeconds = this.getConfigVal(appdef.DefConfigKeyTokenExpirationSecsAccess);
const secureToken = this.createSecureToken(payload, expirationSeconds);
return secureToken;
}
createSecureToken(payload, expirationSeconds) {
// add stuff to payload
payload.iat = Math.floor(Date.now() / 1000);
payload.iss = this.getConfigVal(appdef.DefConfigKeyTokenIssuer);
// expiration?
if (expirationSeconds > 0) {
payload.exp = Math.floor(Date.now() / 1000) + expirationSeconds;
}
// make it
const serverJwtCryptoKey = this.getConfigVal(appdef.DefConfigKeyTokenCryptoKey);
const tokenval = jsonwebtoken.sign(payload, serverJwtCryptoKey);
// return an object that contains both the (encrypted) packed token string, but also the expiration date in plain text
const tokenObj = {
token: {
val: tokenval,
type: payload.type,
exp: payload.exp,
scope: payload.scope,
},
};
return tokenObj;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
isDevelopmentMode() {
return (this.getConfigVal(appdef.DefConfigKeyNodeEnv) === "development");
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
shouldIgnoreError(err) {
if (err === "IGNORE_EXCEPTION") {
// ATTN: purposeful ignorable exception; its used in jrh_express to trick the system into throwing an exception
return true;
}
return false;
}
shouldLogError(err) {
if (err !== undefined && err.status !== undefined) {
if (err.status === 404) {
return false;
}
}
if (err !== undefined && err.code === "EBADCSRFTOKEN") {
// thrown by csrf
return false;
}
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// ATTN: 4/2/20 -- these are really confusing and seem to be overlapping
// and confusion seems greater than when initially coded.. I think they need to be reorganized..
setupExpressErrorHandlers(expressApp) {
// catch 404 and forward to error handler
const mythis = this;
expressApp.use(async function myCustomErrorHandler404(req, res, next) {
const jrContext = JrContext.makeNew(req, res, next);
// if we get here, nothing else has caught the request, so WE push on a 404 error for the next handler
// ATTN: 3/31/20 -- this is newly trapping here other exceptions, such as on calcExpressMiddlewareGetFileLine in jrh_express with a deliberately thrown exception
// so now we check to make sure we don't handle the stuff we shouldn't
if (mythis.shouldIgnoreError(req)) {
return;
}
const handled = false;
if (req !== undefined) {
// this doesn't display anything, just handled preliminary recording, etc.
if (req.url !== undefined) {
await mythis.handle404Error(jrContext);
} else {
// this isn't really a 404 error??! for example can get triggered on a generic exception thrown..
}
}
// ATTN: should this be done even if its not a real 404 error?? (i.e. no req.url)
// then this gets called even if its a 404
if (next !== undefined) {
next(httpErrors(404));
}
});
// and then this is the fall through NEXT handler, which gets called when an error is unhandled by previous use() or pushed on with next(httperrors())
// NOTE that this will also be called by a 404 error
// this can get called for example on a passport error
// error handler
expressApp.use(async function myFallbackErrorHandle(err, req, res, next) {
const jrContext = JrContext.makeNew(req, res, next);
// decide whether to show full error info
if (mythis.shouldIgnoreError(err)) {
return;
}
if (err === undefined || err.status === undefined) {
const stack = new Error().stack;
const fullerError = {
message: "Uncaught error",
status: 0,
stack,
err,
};
err = fullerError;
}
// error message (e.g. "NOT FOUND")
const errorMessage = err.message;
// error status (e.g. 404)
const errorStatus = err.status;
// error details
let errorDetails = "";
// add url to display
if (jrContext.req !== undefined && jrContext.req.url !== undefined) {
errorDetails += "\nRequested url: " + jrContext.req.url;
const originalUrl = jrhExpress.reqOriginalUrl(jrContext.req);
if (req.url !== originalUrl) {
errorDetails += " (" + originalUrl + ")";
}
}
// extra details if in development mode
let errorDebugDetails = "";
if (mythis.isDevelopmentMode() && err.status !== 404) {
errorDebugDetails = mythis.isDevelopmentMode() ? err.stack : "";
}
// extra (session) error info
let jrResultError;
if (req !== undefined) {
jrResultError = jrContext.mergeSessionMessages();
} else {
jrResultError = new JrResult();
}
// ATTN: 4/2/20 is this a serious error? if so, log (and possibly email) it
if (mythis.shouldLogError(err)) {
// log the actual exception error plus extra
let errorLog = err;
if (jrResultError && jrResultError.isError()) {
errorLog += "\n" + jrResultError.getErrorsAsString();
}
await mythis.handleUncaughtError(errorLog);
}
// render the error page
if (jrContext.res !== undefined) {
jrContext.res.status(err.status || 500);
jrContext.res.render("error", {
errorStatus,
errorMessage,
errorDetails,
errorDebugDetails,
jrResult: jrResultError,
});
} else {
// only thing to do is exit?
if (true) {
process.exitCode = 2;
process.exit();
}
}
});
// set up some fatal exception catchers, so we can log these things
// most of our exceptions trigger this because they happen in an await aync with no catch block..
process.on("unhandledRejection", (reason, promise) => {
if (true && reason !== undefined) {
// just throw it to get app to crash exit
// console.log("In unhandledRejection rethrowing reason:");
// console.log(reason);
// console.log("promise");
// console.log(promise);
throw reason;
}
// handle it specifically
// is there a way for us to get the current request being processes
fs.writeSync(
process.stderr.fd,
`unhandledRejection: ${reason}\n promise: ${promise}`,
);
const err = {
message: "unhandledRejection",
reason,
// promise,
};
// report it
this.handleFatalError(err);
// now throw it to get app to crash exit
throw reason;
});
process.on("uncaughtException", async (err, origin) => {
// the problem here is that nodejs does not want us calling await inside here and making this async
// @see https://stackoverflow.com/questions/51660355/async-code-inside-node-uncaughtexception-handler
// but that makes it difficult to log fatal errors, etc. since our logging functions are async.
// SO we kludge around this by using an async handler here, which nodejs does not officially support
// the side effect of doing so is that nodejs keeps rethrowing our error and never exists
// our solution is to set a flag and force a manual exit when it recurses
if (err.escapeLoops) {
console.log("\n\n----------------------------------------------------------------------");
console.log("Encountered UncaughtException forcing process exit, error:");
console.log(err);
console.log("----------------------------------------------------------------------\n\n");
// shutdown profiler?
this.disEngageProfilerIfAppropriate();
process.exit();
return;
}
console.log("Encountered UncaughtException, logging.");
// add flag to it to prevent infinite loops
err.escapeLoops = true;
// handle the fatal error (by logging it presumably)
await this.handleFatalError(err);
// wrap error for re-throwing so we don't recursively loop
const reerr = {
err,
escapeLoops: true,
origin,
};
const flagExit = this.getConfigVal(appdef.DefConfigKeyExitOnFatalError);
if (flagExit) {
// shutdown profiler early, in case we crash trying to shutdown server
this.disEngageProfilerIfAppropriate();
// attempt to try to shutdown
// ATTN: THIS DOES NOT WORK CLEANLY -- AFTER THIS UNCAUGHT EXCEPTION I CAN'T GET APP TO CLEANLY RUN SHUTDOWN STUFF -- it seems to balk
await this.shutDown();
// throw it up, this will display it on console and crash out of node
throw reerr;
} else {
console.log("\n\n--- Not exiting despite fatal error, because config value of EXIT_ON_FATAL_ERROR = false. ---\n\n");
}
});
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async handleUncaughtError(err) {
console.log("Encountered UncaughtError, logging.");
const errString = "Uncaught error occurred on " + jrhMisc.getNiceNowString() + ":\n\n" + jrhMisc.objToString(err, false);
// dummy jrContext since we don't know any other way to get req/res
const jrContext = JrContext.makeNew();
if (true) {
// log the critical error to file and database
try {
await this.logr(jrContext, appdef.DefLogTypeErrorCriticalException, errString);
} catch (exceptionError) {
err.loggingException = exceptionError;
jrdebug.debugObj(err, "Exception while trying to log uncaught error");
}
}
if (true) {
// let's log it on screen:
console.error(errString);
}
}
async handleFatalError(err) {
// ATTN: test
if (true) {
// log the critical error to file and database
try {
const errString = "Fatal/critical error occurred on " + jrhMisc.getNiceNowString() + ":\n\n" + jrhMisc.objToString(err, false);
// dummy jrContext since we don't know any other way to get req/res
const jrContext = JrContext.makeNew();
await this.logr(jrContext, appdef.DefLogTypeErrorCriticalException, errString);
} catch (exceptionError) {
err.loggingException = exceptionError;
jrdebug.debugObj(err, "Exception while trying to log fatal error");
}
}
process.exitCode = 2;
}
async handle404Error(jrContext) {
// caller will pass this along to show 404 error to user; we can do extra stuff here
if (true) {
const msg = {
url: jrContext.req.url,
};
await this.logr(jrContext, appdef.DefLogTypeError404, msg);
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* This may be called on serious errors (server down, database offline, unexpected exception, etc.), when we want to alert admin(s) immediately via email or sms
* It does some rate-limiting to prevent generating too many messages, etc.
*
* @param {string} eventType
* @param {string} subject
* @param {string} message
* @param {req} express request (or null if not part of one)
* @param {object} extraData
* @param {boolean} flagAlsoSendToSecondaries - if false, only the main admin is notified; if true then the primary AND secondary list of emails, etc. is notified
* @returns number of messages sent
*/
async emergencyAlert(jrContext, eventType, subject, message, req, extraData, flagAlsoSendToSecondaries, flagOverrideRateLimiter) {
// first we check rate limiting
let messageSentCount = 0;
if (!flagOverrideRateLimiter) {
const rateLimiter = this.getRateLimiterEmergencyAlert();
// ATTN: with rateLimiterKey == "" it means that we share a single rate limter for all emergencyAlerts
const rateLimiterKey = "";
//
try {
// consume a point of action
await rateLimiter.consume(rateLimiterKey, 1);
} catch (rateLimiterRes) {
// rate limiter triggered; if this is not our FIRST trigger of the emergency alert rate limiter within this time period, then just silently return
if (!rateLimiterRes.isFirstInDuration) {
return 0;
}
// otherwise we will not send the alert but we will send a message about turning off emergency alerts
// send them a message saying emergency alerts are disabled for X amount of time
const blockTime = rateLimiterRes.msBeforeNext / 1000.0;
// send a message saying we are disabling emergency alerts
const esubject = util.format("Emergency alerts temporarily disabled for %d seconds", blockTime);
const emessage = util.format("Due to rate limiting, no further alerts will be sent for %d seconds.", blockTime);
await this.emergencyAlert(jrContext, "ratelimit.emergency", esubject, emessage, req, {}, false, true);
// now return saying we did not send the alert
return 0;
}
}
// ok send the message
// who gets it?
let recipients = this.getEmergencyAlertContactsPrimary();
if (flagAlsoSendToSecondaries) {
recipients = jrhMisc.mergeArraysKeepDupes(recipients, this.getEmergencyAlertContactsSecondary());
}
// add req info to extra data of message
let extraDataPlus;
if (req) {
// add req info
const ip = (req.ip && req.ip.length > 7 && req.ip.substr(0, 7) === "::ffff:") ? req.ip.substr(7) : req.ip;
extraDataPlus = {
...extraData,
req_userid: this.getUntrustedLoggedInUserIdFromSession(jrContext),
req_ip: ip,
};
} else {
extraDataPlus = extraData;
}
// announce on console
jrdebug.debug("Emergency error alert triggered (see error log): " + subject);
// loop and send to all recipients
await jrhMisc.asyncAwaitForEachFunctionCall(recipients, async (recipient) => {
// do something
await this.sendAid.sendMessage(jrContext, recipient, subject, message, extraDataPlus, true);
++messageSentCount;
});
// done
return messageSentCount;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Setup any global hooks not covered by express
*
* @memberof AppRoomServer
*/
setupGlobalHooks() {
process.on("exit", async () => {
// try to shutdown, if not already done, on exit
// console.log("Exiting application.");
await this.shutDown();
});
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Helper function used by some simple test pages (see admin/test), where we just want to check user's access, then show user a message and have them CONFIRM that they really do want to perform a given action
*
* @param {*} req
* @param {*} res
* @param {*} permission
* @param {*} headline
* @param {*} message
* @returns false if permission fails and displaying error message (caller needs do nothing); actually it should throw an error and not return on failure to meet permission
* @memberof AppRoomServer
*/
async confirmUrlPost(jrContext, permission, headline, message) {
if (!await this.aclRequireLoggedInSitePermissionRenderErrorPageOrRedirect(jrContext, permission)) {
// all done
return false;
}
jrContext.res.render("generic/confirmpage", {
jrResult: jrContext.mergeSessionMessages(),
csrfToken: this.makeCsrf(jrContext),
headline,
message,
formExtraSafeHtml: "",
});
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
engageProfilerIfAppropriate() {
// see https://npmdoc.github.io/node-npmdoc-v8-profiler/build/apidoc.html#apidoc.element.v8-profiler.startProfiling
if (this.getOptionProfileEnabled()) {
profiler = require("v8-profiler-next");
jrdebug.debug("Engaging v8 profiler.");
profiler.startProfiling("", true);
}
}
disEngageProfilerIfAppropriate() {
if (this.getOptionProfileEnabled() && profiler !== undefined) {
const profileResult = profiler.stopProfiling("");
const profileOutputPath = this.getProfileOutputFile();
profileResult.export().pipe(fs.createWriteStream(profileOutputPath)).on("finish", () => {
profileResult.delete();
const errmsg = "Wrote profile data to " + profileOutputPath + ".";
jrdebug.debug(errmsg);
});
profiler = undefined;
}
}
getProfileOutputFile() {
return this.getLogDir() + "/" + this.getLogFileBaseName() + "_" + jrhMisc.getCompactNowString() + ".cpuprofile";
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async autoLoginUserViaAuthHeaderToken(jrContext) {
// see https://stackoverflow.com/questions/46094417/authenticating-the-request-header-with-express
// ATTN: the only problem here is that the caller is responsible for showing any error to user since
// check cache so we only do this once
if (this.getCachedFlagAuthHeaderChecked(jrContext)) {
return;
}
this.setCachedFlagAuthHeaderChecked(jrContext, true);
// first check if authorization info passed in header, if not just return false
if (!jrContext.req.headers || !jrContext.req.headers.authorization) {
// no auth headers so no point checking
return;
}
// QUESTION: Should we store user in session when they use autho token?
// Answer: NO. In general if user is using auth bearer token in header, we want them to do so on EVERY request (presumably using a tool); rather than doing so once and remembering them in session.
const flagRememberUserInSessionCookie = false;
// ok we think there is authorization header (jwt)
// note we do not pass a result res reference in, and askk passport authenticate to not add any error info or reroute, etc. this is less than ieal in some cases
await this.asyncRoutePassportAuthenticate(jrContext, "jwtHeader", "via jwt authorization header token", flagRememberUserInSessionCookie, false, false, false);
if (jrContext.isError()) {
// clear sessioned user
this.clearSessionedUser(jrContext.req);
// drop down to return it
} else {
// no point pushing success message on
// jrContext.pushSuccess("JWT Authentication token accepted.");
}
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
validateSecureToken(jrResult, tokenObj, requiredTokenType) {
// check type
const tokenType = tokenObj.type;
if (requiredTokenType && tokenType !== requiredTokenType) {
jrResult.pushErrorOnTop("Error code B1: expected token type [" + requiredTokenType + "] but received type [" + tokenType + "]");
}
// check expiration
if (this.isSecureTokenExpired(tokenObj.exp)) {
if (tokenObj.exp) {
const expirationdatestr = new Date(tokenObj.exp * 1000).toLocaleString();
jrResult.pushError("Error code B2: token expired on " + expirationdatestr + ".");
} else {
jrResult.pushError("Error code B3: token missing expiration date.");
}
}
// check issuer
const expectedIssuer = this.getConfigVal(appdef.DefConfigKeyTokenIssuer);
if (tokenObj.iss !== expectedIssuer) {
// wrong issues
jrResult.pushError("Error code B4: expected issuer [" + expectedIssuer + "] but received [" + tokenObj.iss + "]");
}
// check other stuff
if (!tokenObj.type) {
// missing token type
jrResult.pushError("Error code B5: token missing type tag.");
}
}
isSecureTokenExpired(exp) {
// ATTN:TODO - do we need to worry about local vs gmt/etc time?
if (!exp) {
// what to do in this case? no expiration date?
// ATTN:TODO - for now we treat it as ok
return true;
}
if (exp <= Math.floor(Date.now() / 1000)) {
// it's expired
return true;
}
// its not expired
return false;
}
validateSecureTokenAccess(jrResult, tokenObj) {
// here we check that the token is an access type and other details of it
// see also validateSecureToken()
this.validateSecureToken(jrResult, tokenObj, "access");
return jrResult;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
renderError(jrContext, errorStatusCode, renderFormat) {
if (renderFormat === "json") {
if (jrContext.getExtraData("tokenError")) {
// specialty token error
jrhExpress.sendJsonErrorAuthToken(jrContext);
} else {
// generic error
jrhExpress.sendJsonResult(jrContext, errorStatusCode);
}
} else if (renderFormat === "html") {
// send html result
throw new Error("Don't know how to show html renderFormat errors yet in arserver.renderError.");
} else {
throw new Error("Unknow renderFormat in arserver.renderError: " + renderFormat);
}
}
renderErrorJson(jrContext, errorStatusCode) {
return this.renderError(jrContext, errorStatusCode, "json");
}
renderErrorHtml(jrContext, errorStatusCode) {
return this.renderError(jrContext, errorStatusCode, "html");
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getUpdateAccessDateFrequencyInMs() {
return this.getConfigVal(appdef.DefConfigKeyLastAccessUpdateFrequencyMs);
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
makeVirtualLoginToken(loginMethod) {
// create a login token like a jwt auth token to make it easier to check stuff
const token = {
type: "login",
subtype: loginMethod,
};
this.decorateAuthLoginToken(token);
return token;
}
decorateAuthLoginToken(token) {
token.proofDate = Date.now();
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getTimeSinceLoggedInUserLastLoggedInMs(jrContext) {
// ATTN: because we are checking loggedin passport data, this will NOT catch an auth token logged in user; to catch them we must call lookupLoggedInUser first
const profile = this.getLoggedInPassportUsr(jrContext);
if (!profile || !profile.token || !profile.token.proofDate) {
// not available, return null
return null;
}
return Date.now() - profile.token.proofDate;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
async requireRecentLoggedIn(jrContext, recencyMs) {
// We want to do this first for 2 reasons:
// 1. error message saying they need to log in
// 2. the request here will log in user via auth header token if provided, wheras the thin call to getTimeSinceLoggedInUserLastLoggedInMs() will not
if (!await this.requireLoggedIn(jrContext)) {
// all done
return false;
}
// see how long it's been since they last logged in / proved their identity
const timeSinceLastLogin = this.getTimeSinceLoggedInUserLastLoggedInMs(jrContext);
if (timeSinceLastLogin === null || timeSinceLastLogin > recencyMs) {
jrContext.pushMessage("You need to re-login more recently before you can proceed.");
this.divertToLoginPageThenBackToCurrentUrl(jrContext, null);
return false;
}
// all good
return true;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
discoverAddonCollection(collectionName) {
// get the plugin config object
const configKey = this.calcCollectionConfigKey(collectionName);
const allObjs = this.getConfigVal(configKey);
// now iterate over it and register the plugins
let obj;
Object.keys(allObjs).forEach((name) => {
obj = allObjs[name];
if (obj.enabled !== false) {
jrequire.registerAddonModule(collectionName, name, obj);
}
});
}
initializeAddonCollection(collectionName) {
// initialize a configuration collection
let addonModule;
const AllAddons = jrequire.getAllAddonModulesForCollectionName(collectionName);
if (AllAddons) {
Object.keys(AllAddons).forEach((name) => {
addonModule = jrequire.requireAddonModule(collectionName, name);
if (addonModule && addonModule.initialize) {
addonModule.initialize(this);
}
});
}
}
discoverAndInitializeAddonPlugins() {
const collectionName = this.getAddonCollectionNamePlugins();
this.discoverAddonCollection(collectionName);
this.initializeAddonCollection(collectionName);
}
discoverAndInitializeAddonAppFrameworks() {
const collectionName = this.getAddonCollectionNameAppFrameworks();
this.discoverAddonCollection(collectionName);
this.initializeAddonCollection(collectionName);
}
getAddonCollectionNamePlugins() {
return "plugins";
}
getAddonCollectionNameAppFrameworks() {
return "appFrameworks";
}
calcCollectionConfigKey(collectionName) {
return appdef.DefConfigKeyCollections[collectionName];
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getAppFrameworkChoices() {
if (this.cachedAppFrameworkChoices === undefined) {
// calc and cache them
let label;
this.cachedAppFrameworkChoices = {};
const allObjs = jrequire.getAllAddonModulesForCollectionName(this.getAddonCollectionNameAppFrameworks());
Object.keys(allObjs).forEach((name) => {
label = allObjs[name].label ? allObjs[name].label : name;
this.cachedAppFrameworkChoices[name] = label;
});
}
// return it
return this.cachedAppFrameworkChoices;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
getVersionLib() {
return appdef.DefLibVersion;
}
getVersionApi() {
return appdef.DefApiVersion;
}
//---------------------------------------------------------------------------
}
//---------------------------------------------------------------------------
// export A SINGLETON INSTANCE of the class as the sole export
module.exports = new AppRoomServer();
//---------------------------------------------------------------------------