Source: server/SlimmingWorldServer.js

/* 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;