/* global WP_DEFINE_DEVELOPMENT, WP_DEFINE_VERSIONING_DIRNAME, WP_DEFINE_ASSETS_DIRNAME, WP_DEFINE_WEB_OUTPUT_DIRNAME, WP_DEFINE_PUBLIC_PATH, WP_DEFINE_MARKET */ // eslint-disable-line
import path from 'path';
import express, { Router as expressRouter } from 'express';
import httpProxy from 'http-proxy';
import debugLib from 'debug';
import cookieParser from 'cookie-parser';
import responseTime from 'response-time';
import raven from 'raven';
import addShutdown from 'http-shutdown';
import get from 'lodash/get';
import { HealthChecker, LivenessEndpoint, ReadinessEndpoint } from '@cloudnative/health-connect';
// eslint-disable-next-line import/no-extraneous-dependencies,
import promClient from 'prom-client';
import hotPageRenderer from 'hot-callback-loader?export=default!./PageRenderer';
import 'expose-loader?fetch!imports-loader?this=>global!exports-loader?global.fetch!isomorphic-fetch';
import { createClientOnServer } from '../app/util/raven/raven-client';
import { enableDebugBreadcrumb } from '../app/util/raven/debug-breadcrumb';
import marketConfig from '../app/config/market/market.configdefinitions';
import setupImplicitOidcRoutes from './routes/auth/implicitOidcRoutes';
import emailSuccessRoute from './routes/auth/emailSuccessRoute';
import serverConfig from './server.configdefinitions';
import serviceConfig from '../app/config/service.configdefinitions';
import GatewayError from '../app/net/gateway/GatewayError';
import PollingAppSettings from '../app/util/azure/PollingAppSettings';
import AuthenticationHelper from './util/AuthenticationHelper';
import { OID_SERVER_SCOPES, AUTH_CODE_LOGIN_CALLBACK_ROUTE } from '../app/data/AuthConstants';
import setRenderMode from './routes/setRenderMode';
import toggleWeinreBrowserSync from './routes/toggleWeinreBrowserSync';
import OidcDiscovery from './util/AuthenticationHelper/OidcDiscovery';
import { getEnvironmentFromHost, validateConfigs } from './util/monitorUtils';
import RequestScheduler from './util/RequestScheduler';
import isLocalOrDevelopment from './util/isLocalOrDevelopment';
import '../app/util/customizeMomentLocale';
const appInsights = require('applicationinsights');
const HTTP_NOT_FOUND = 404;
const HTTP_INTERNAL_SERVER_ERROR = 500;
// patch debug calls to add them as Raven breadcrumbs
if (!isLocalOrDevelopment()) {
enableDebugBreadcrumb();
}
/**
* Server class that serves our app
*/
class SlimmingWorldServer {
requestScheduler = new RequestScheduler();
httpServer = null;
authHelper = null;
oidcDiscovery = null;
Routes = null;
reduxReducer = null;
running = false;
appInsightsIsSetup = false;
buildInfo = null;
environmentConfig = null;
/**
* @param [afterStoreHook] {function} Will be called when the store is created, so you can
* dispatch actions to add stuff in the store. Will pass the store as first parameter, and the
* express request object as second.
* @param [afterStart] {function} Will be called after the server has started
*/
constructor({ afterStoreHook = null, buildInfo = {}, environmentConfig = null }) {
this.environmentConfig = environmentConfig;
const oidcConfig = this.environmentConfig?.oidc;
// eslint-disable-next-line camelcase
const client_id = oidcConfig?.authorizationCode?.[serviceConfig.webHost]?.client_id;
// eslint-disable-next-line camelcase
const client_secret = oidcConfig?.authorizationCode?.[serviceConfig.webHost]?.client_secret;
this.authConfig = {
client_id,
client_secret,
callbackRoute: AUTH_CODE_LOGIN_CALLBACK_ROUTE,
scopes: OID_SERVER_SCOPES,
configuration_endpoint: `${oidcConfig.authority}/.well-known/openid-configuration`,
};
this.afterStoreHook = afterStoreHook;
this.buildInfo = buildInfo;
this.app = express();
this.appRouter = expressRouter();
this.debug = debugLib('SlimmingWorld:SlimmingWorldServer');
this.debug(`running a "${marketConfig.marketName}" market build`);
this.ravenConfig = this.environmentConfig?.raven?.nodejs;
this.initAuthenticationDiscovery();
if (serverConfig.useRaven && this.ravenConfig) {
this.setupRaven();
}
if (isLocalOrDevelopment()) {
this.requestScheduler.enable();
}
// Only create the reverse proxy if proxy is configured in config files.
const TARGET_PROXY = this.environmentConfig?.proxy?.server;
if (TARGET_PROXY) {
this.apiProxy = httpProxy.createServer();
}
hotPageRenderer(PageRenderer => {
this.pageRenderer = new PageRenderer();
this.pageRenderer.setSetupInjects(this.environmentConfig);
this.pageRenderer.setEnvironmentConfig(this.environmentConfig);
this.pageRenderer.setReactRoutes(this.Routes);
this.pageRenderer.setReduxReducer(this.reduxReducer);
if (this.oidcDiscovery) {
this.pageRenderer.setOidcDiscovery(this.oidcDiscovery);
}
});
}
/**
* Set the react-router configuration component that should be used to match incoming
* requests.
* @param Routes The react-router configuration component
*/
setReactRoutes = Routes => {
this.Routes = Routes;
if (this.pageRenderer) {
this.pageRenderer.setReactRoutes(Routes);
}
};
/**
* Sets the main reducer to be used when creating new store instances.
* @param reduxReducer A redux reducer function
*/
setReduxReducer = reduxReducer => {
this.reduxReducer = reduxReducer;
if (this.pageRenderer) {
this.pageRenderer.setReduxReducer(reduxReducer);
}
};
/**
* Sets the environmentConfig instance to set the configurations settings
* @param environmentConfig
* @returns {*}
*/
setEnvironmentConfig = environmentConfig => {
this.environmentConfig = environmentConfig;
if (this.pageRenderer) {
this.pageRenderer.setSetupInjects(environmentConfig);
this.pageRenderer.setEnvironmentConfig(environmentConfig);
}
};
/**
* All middleware that should be mounted before the regular routing should be added
* before calling this function.
*/
start() {
if (this.running) {
this.debug(
'Attempting to call start() on a SlimmingWorldServer instance that is already running',
);
return;
}
this.debug('Starting SlimmingWorldServer');
this.initMiddleware();
this.running = true;
this.listen();
this.setupAppInsights();
}
setupAppInsights() {
// check if setup is not done already. should not be neccessary but just in case
if (!this.appInsightsIsSetup) {
this.appInsightsIsSetup = true;
const getBooleanValue = (value, defaultValue = true) =>
typeof value !== 'undefined' ? value : defaultValue;
if (process.env.APPINSIGHTS_INSTRUMENTATIONKEY && process.env.APPINSIGHTS_ENABLED) {
const webHost = serviceConfig.webHost;
appInsights
.setup(process.env.APPINSIGHTS_INSTRUMENTATIONKEY)
.setAutoDependencyCorrelation(
getBooleanValue(process.env.APPINSIGHTS_AUTO_DEPENDENCY_CORRELATION),
)
.setAutoCollectRequests(getBooleanValue(process.env.APPINSIGHTS_AUTO_COLLECT_REQUESTS))
.setAutoCollectPerformance(
getBooleanValue(process.env.APPINSIGHTS_AUTO_COLLECT_PERFORMANCE),
)
.setAutoCollectExceptions(
getBooleanValue(process.env.APPINSIGHTS_AUTO_COLLECT_EXCEPTIONS),
)
.setAutoCollectDependencies(
getBooleanValue(process.env.APPINSIGHTS_AUTO_COLLECT_DEPENDENCIES),
)
.setAutoCollectConsole(getBooleanValue(process.env.APPINSIGHTS_AUTO_COLLECT_CONSOLE))
.setUseDiskRetryCaching(getBooleanValue(process.env.APPINSIGHTS_USE_DISK_RETRY_CACHING));
appInsights.defaultClient.config.samplingPercentage =
parseFloat(process.env.APPINSIGHTS_SAMPLING_PERCENTAGE) || 100;
appInsights.defaultClient.context.tags[
appInsights.defaultClient.context.keys.cloudRole
] = `${WP_DEFINE_MARKET}-${webHost}-frontend`;
appInsights.start();
const Agent = require('agentkeepalive'); // eslint-disable-line global-require
const HttpsAgent = require('agentkeepalive').HttpsAgent; // eslint-disable-line global-require
const keepAliveConfig = {
maxSockets: 1,
maxFreeSockets: 5,
timeout: 60000,
keepAliveTimeout: 300000,
};
appInsights.defaultClient.config.httpAgent = new Agent(keepAliveConfig);
appInsights.defaultClient.config.httpsAgent = new HttpsAgent(keepAliveConfig);
}
// appInsights.defaultClient.addTelemetryProcessor((envelope, context) => {
// console.log(context);
// });
}
}
/**
* Setup middleware to log all requests through the debug module
*/
logRequests() {
this.app.use('/', (req, res, next) => {
this.debug(`Request: ${req.protocol}://${req.get('host')}${req.originalUrl}`);
next();
});
}
/**
* Enables serving static files from the build folder.
*/
serveStaticFiles() {
/* eslint-disable no-unused-vars */
// serve static files from versioning folder
this.app.use(
`/${WP_DEFINE_VERSIONING_DIRNAME}`,
express.static(
path.join(__dirname, `../${WP_DEFINE_WEB_OUTPUT_DIRNAME}/${WP_DEFINE_VERSIONING_DIRNAME}`),
),
);
// if requests to versioning folder are not handled by this point, respond 404
this.app.use(`/${WP_DEFINE_VERSIONING_DIRNAME}`, (req, res, next) =>
res.sendStatus(HTTP_NOT_FOUND),
);
this.app.use('/', express.static(path.join(__dirname, `../${WP_DEFINE_WEB_OUTPUT_DIRNAME}`)));
/* eslint-enable */
}
serveRoute = (route, handler) => {
this.app.use(route, handler);
};
/**
* Enables serving web assets from the build folder
*/
serveBuildAssetFiles() {
/* eslint-disable no-unused-vars */
// serve static files from versioning folder
this.app.use(
`/${WP_DEFINE_ASSETS_DIRNAME}`,
express.static(
path.join(__dirname, `../${WP_DEFINE_WEB_OUTPUT_DIRNAME}/${WP_DEFINE_ASSETS_DIRNAME}`),
),
);
// if requests to versioning folder are not handled by this point, respond 404
this.app.use(`/${WP_DEFINE_ASSETS_DIRNAME}`, (req, res, next) =>
res.sendStatus(HTTP_NOT_FOUND),
);
/* eslint-enable */
}
initAuthenticationDiscovery() {
this.oidcDiscovery = new OidcDiscovery(
this.authConfig,
serviceConfig.useServerAuthentication || serviceConfig.useOidcDiscovery,
);
if (this.pageRenderer) {
this.pageRenderer.setOidcDiscovery(this.oidcDiscovery);
}
}
setupReverseProxy() {
// Only set these routes to reserve proxy if proxy is configured in config files.
const TARGET_PROXY = this.environmentConfig?.proxy?.server;
if (TARGET_PROXY) {
const redirectToProxy = (req, res) => {
delete req.headers.host; // eslint-disable-line no-param-reassign
this.apiProxy.web(req, res, { target: TARGET_PROXY });
};
[
// GET
'/request-logout',
'/api/v1/invites/{id}',
'/api/v1/antiforgery',
'/api/v1/accounts/me',
'/api/v1/accounts/me/full-name',
'/api/v1/accounts/account-state',
'/api/v1/subscription-packages',
'/api/v2/components/account-login-background',
'/.well-known/openid-configuration',
].forEach(route => this.app.get(route, redirectToProxy));
[
// HEAD
'/verify-password-token',
'/api/v1/accounts',
'/api/v1/accounts?userName={userName}',
].forEach(route => this.app.head(route, redirectToProxy));
[
// POST
'/login',
'/login/callback',
'/logout',
'/change-email',
'/confirm-email',
'/confirm-email-change',
'/change-password',
'/confirm-password-reset',
'/request-password-reset',
'/api/v1/accounts/{id}/password/request-reset',
'/api/v1/subscription-packages/{slug}/purchase',
].forEach(route => this.app.post(route, redirectToProxy));
}
}
/**
* Initializes the default routes and middleware for the server. Should only be called once.
*/
initMiddleware() {
const cookieConfig = this.environmentConfig?.cookie;
this.app.use(cookieParser(cookieConfig.signSecret));
this.setupMetrics();
this.setupMonitoring();
this.setupHealthCheck();
this.setupHTTPSHeader();
this.setupReverseProxy();
if (process.env.NODE_ENV !== 'production') {
setRenderMode(this.app);
toggleWeinreBrowserSync(this.app);
this.app.use(responseTime());
} else {
this.app.set('etag', false);
this.app.set('x-powered-by', false);
}
this.logRequests();
if (serviceConfig.useServerAuthentication) {
this.authHelper = new AuthenticationHelper(this.authConfig, this.oidcDiscovery);
emailSuccessRoute(this.app);
this.authHelper.createRoutes(this.app);
if (serverConfig.useRaven && this.ravenConfig) {
// TODO: pass user info to raven
// see: https://slimmingworlddigital.atlassian.net/browse/INF-101
}
}
if (serviceConfig.useClientAuthentication) {
setupImplicitOidcRoutes(this.app);
}
this.appRouter.use('/', (req, res, next) => {
appInsights &&
appInsights.defaultClient &&
appInsights.defaultClient.trackNodeHttpRequest({ request: req, response: res });
const pollingAppSettings = new PollingAppSettings();
pollingAppSettings.initRefresh(async config => {
await this.setEnvironmentConfig(config);
});
// patch redirect to work with this mounted route
const orgRedirect = res.redirect;
/* eslint-disable no-param-reassign */
res.redirect = (code, url) => {
if (typeof code === 'string') {
url = code;
code = 302;
}
if (url.startsWith('/')) {
url = req.baseUrl + url;
}
orgRedirect.call(res, code, url);
};
/* eslint-enable no-param-reassign */
const debugMode = !!req.query.debugMode;
this.requestScheduler.scheduleRequest(
() =>
this.pageRenderer.handleRequest(
req,
res,
next,
{
authHelper: this.authHelper,
afterStoreHook: this.afterStoreHook,
},
debugMode,
),
debugMode,
);
});
this.app.use(WP_DEFINE_PUBLIC_PATH || '/', this.appRouter);
if (serverConfig.useRaven && this.ravenConfig) {
this.setupRavenErrorHandler();
}
/* eslint-disable no-unused-vars */
this.app.use((err, req, res, next) => {
this.debug('Error during request: ');
this.debug(err);
if (
!process.env.NODE_ENV ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'localhost'
) {
res.status(HTTP_INTERNAL_SERVER_ERROR).send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SlimmingWorldServer Error</title>
</head>
<body style="font-family: sans-serif">
<h1>SlimmingWorldServer Error</h1>
<h3>${err.message}</h3>
<br />
<h4>Stack Trace:</h4>
<pre>${err.stack}</pre>
</body>
</html>`);
} else {
res.sendStatus(HTTP_INTERNAL_SERVER_ERROR);
}
});
/* eslint-enable */
}
/**
* Sets up raven for reporting NodeJS rendering issues to Sentry
*/
setupRaven() {
createClientOnServer(raven, this.ravenConfig, this.buildInfo);
if (!isLocalOrDevelopment()) {
this.app.use(raven.requestHandler());
}
}
/**
* Sets up raven error handling middleware for reporting NodeJS rendering issues to Sentry
*/
setupRavenErrorHandler() {
if (!isLocalOrDevelopment()) {
const ravenErrorHandler = raven.errorHandler();
this.app.use((error, req, res, next) => {
if (error instanceof GatewayError) {
return next(error);
}
return ravenErrorHandler(error, req, res, next);
});
}
}
/**
* If the header x-iisnode-https is set by iisnode, set the more standardized header
* x-forwarded-proto
*/
setupHTTPSHeader() {
this.app.use('/', (req, res, next) => {
if (req.headers['x-iisnode-https'] === 'on') {
/* eslint-disable no-param-reassign */
req.headers['x-forwarded-proto'] = 'https';
/* eslint-enable */
}
next();
});
}
setupMetrics() {
this.app.get('/metrics', async (req, res) => {
try {
const collectDefaultMetrics = promClient.collectDefaultMetrics;
const Registry = promClient.Registry;
const register = new Registry();
collectDefaultMetrics({ register });
res.set('Content-Type', register.contentType);
res.send(await register.metrics());
} catch (ex) {
res.status(500).end(ex);
}
});
}
setupMonitoring() {
this.app.use('/monitor', (req, res) => {
let result = {};
try {
const envFromUrl = getEnvironmentFromHost(req.headers.host);
const configArray = [];
configArray.push({
name: 'apiConfig',
properties: [
'api.account.host',
'api.content.host',
'api.community.host',
'api.live.host',
'api.food.host',
].map(key => ({ key, value: get(this.environmentConfig, key) })),
});
configArray.push({
name: 'webConfig',
properties: [
'web.account.host',
'web.member.host',
'web.planner.host',
'web.public.host',
'web.admin.host',
].map(key => ({ key, value: get(this.environmentConfig, key) })),
});
configArray.push({
name: 'chatConfig',
properties: ['live.server'].map(key => ({
key,
value: get(this.environmentConfig, key),
})),
});
configArray.push({
name: 'oidcConfig',
properties: ['oidc.authority'].map(key => ({
key,
value: get(this.environmentConfig, key),
})),
});
const configValidationResults = validateConfigs(configArray, envFromUrl);
result = {
allOk: configValidationResults.every(c => c.isOk),
configurations: configValidationResults,
};
} catch (e) {
result = {
allOk: false,
errorMessage: 'An internal error occurred while trying to perform monitoring tasks',
};
// eslint-disable-next-line no-console
console.error(e);
}
res.json(result);
});
}
setupHealthCheck() {
const healthcheck = new HealthChecker();
/* eslint-disable new-cap */
this.app.use('/health/live', LivenessEndpoint(healthcheck));
this.app.use('/health/ready', ReadinessEndpoint(healthcheck));
/* eslint-enable*/
}
/**
* Helper function called when executing start(). Will cause express to start listening on
* the configured port. All routes should be setup before calling this method.
*/
listen() {
this.port = process.env.PORT || serverConfig.port;
this.httpServer = this.app.listen(this.port);
if (WP_DEFINE_DEVELOPMENT) {
addShutdown(this.httpServer);
}
this.debug(`listening on port ${this.port}`);
}
/**
* Helper function called when executing stop(). Will close the currently running http server
* so a new one can be started.
*/
close() {
if (this.httpServer) {
this.debug(`closing http server at port ${this.port}`);
if (WP_DEFINE_DEVELOPMENT) {
this.httpServer.shutdown(() => this.debug('server closed using http-shutdown'));
} else {
this.httpServer.close(() => this.debug('server closed'));
}
this.httpServer = null;
}
}
/**
* Stops the currently running express instance
*/
stop() {
if (!this.running) {
this.debug('Attempting to call stop() on a SlimmingWorldServer instance that is not running');
return;
}
this.close();
this.running = false;
}
}
export default SlimmingWorldServer;