/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const _ = require('lodash');
const constants = require('../../constants');
const logger = require('../../core/logger')('contrast:sources:express');
const patcher = require('../../hooks/patcher.js');
const { PATCH_TYPES } = require('../../constants');
const moduleHook = require('../../hooks/require.js');
const Helpers = require('./utils');
const agentEmitter = require('../../agent-emitter');
const { isString } = require('../../util/is-string');
const { emitSendEvent } = require('../../hooks/frameworks/common');
const {
  prototype: { decorateRequest },
  setFrameworkRequest,
} = require('../../hooks/frameworks/base');
const ExpressRequest = require('../../reporter/models/frameworks/express-request');

// tracks what routers have been visited
// by weaveErrorHandlers to prevent infinite loop
const visitedRouters = new Set();

const {
  LC_HTTP_VERBS,
  HTTP_EVENTS,
  INPUT_TYPES,
  SINK_TYPES,
  MW_PATH,
  LAYER_STACK,
} = constants;
const { EVENTS } = Helpers;

class ExpressFramework {
  constructor(agent) {
    // id:{id:'',segments:[],verbs:{},route:'',urls:[]}
    this.routeInfos = {};
    this.appsSeen = new WeakSet();
    // keeps track of routes discovered vs in stack
    this.stacksDiscovered = new WeakMap();
    // Associating server instances with apps. It is possible to have multiple
    // servers per app.
    this.serversSeen = new WeakMap();
    // name : { post: fn }
    this.handlers = {};
    // Express middleware error handler.
    this.errorHandler = ExpressFramework.ContrastErrorHandler.bind(this);
    moduleHook.resolve({ name: 'express' }, this.hookExpress.bind(this));
    moduleHook.resolve({ name: 'body-parser' }, this.hookBodyParser.bind(this));
  }

  /**
   * Adds middleware after the original middleware passed to <code>use</code>.
   */
  injectContrastMiddleware(data) {
    // XXX it looks like 2 different router instances can share a stack
    // so we need to de-dupe on the stack rather than the router instance itself
    // or on the 'this'
    const stack = ExpressFramework.getStack(data.obj);
    if (!stack.__contrastMW) {
      stack.__contrastMW = {};
    }

    const args = [];
    data.args.forEach((arg) => {
      if (typeof arg === 'function' && arg.length === 3) {
        const { name } = arg;
        const handler = this.handlers[name];
        if (handler) {
          const { post } = handler;
          args.push(arg);

          if (post && !stack.__contrastMW[name]) {
            args.push(post);
            stack.__contrastMW[name] = true;
          }
        } else {
          args.push(arg);
        }
      } else {
        args.push(arg);
      }
    });
    data.args = args;
  }

  /**
   * Ensure that we catch when express is loaded. We apply patches here
   * and strategically insert all of our middleware functions that are
   * listening for events, or catching block errors.
   */
  hookExpress(express) {
    const self = this;
    self.createMiddlewareWatchers();
    patcher.patch(express.response, 'send', {
      name: 'express.response.send',
      patchType: PATCH_TYPES.PROTECT_SINK,
      pre(data) {
        agentEmitter.emit(
          EVENTS.REQUEST_SEND,
          data.args[0],
          SINK_TYPES.RESPONSE_BODY,
        );

        const [body] = data.args;
        if (isString(body)) {
          emitSendEvent(body.valueOf());
        }
      },
    });

    patcher.patch(express.response, 'push', {
      name: 'express.response.push',
      patchType: PATCH_TYPES.PROTECT_SINK,
      pre(data) {
        agentEmitter.emit(
          EVENTS.REQUEST_SEND,
          data.args[0],
          SINK_TYPES.RESPONSE_BODY,
        );

        const [body] = data.args;
        if (isString(body)) {
          emitSendEvent(body.valueOf());
        }
      },
    });

    patcher.patch(express.response, 'end', {
      name: 'express.response.end',
      patchType: PATCH_TYPES.PROTECT_SINK,
      pre(data) {
        agentEmitter.emit(
          EVENTS.REQUEST_SEND,
          data.args[0],
          SINK_TYPES.RESPONSE_BODY,
        );
      },
    });

    patcher.patch(express.Router, 'use', {
      name: 'express.Router',
      patchType: PATCH_TYPES.FRAMEWORK,
      alwaysRun: true,
      pre: self.injectContrastMiddleware.bind(self),
      post: (wrapCtx) => {
        agentEmitter.emit(EVENTS.ROUTER_USE, self, wrapCtx);
      },
    });

    patcher.patch(express.Router, 'route', {
      name: 'express.Router',
      patchType: PATCH_TYPES.ROUTE_COVERAGE,
      alwaysRun: true,
      post: (wrapCtx) => {
        agentEmitter.emit(EVENTS.ROUTER_ROUTE, self, wrapCtx);
      },
    });

    LC_HTTP_VERBS.forEach((verb) => {
      patcher.patch(express.Router, verb, {
        alwaysRun: true,
        name: 'express.Router',
        patchType: PATCH_TYPES.ROUTE_COVERAGE,
        pre: self.injectContrastMiddleware.bind(self),
        post: (wrapCtx) => {
          agentEmitter.emit(EVENTS.ROUTER_METHOD, self, wrapCtx, verb);
        },
      });
    });

    patcher.patch(express.Router, 'all', {
      alwaysRun: true,
      name: 'express.Router',
      patchType: PATCH_TYPES.ROUTE_COVERAGE,
      pre: self.injectContrastMiddleware.bind(self),
      post: (wrapCtx) => {
        agentEmitter.emit(EVENTS.ROUTER_METHOD, self, wrapCtx, 'all');
      },
    });

    patcher.patch(express.application, 'use', {
      name: 'express.application',
      patchType: PATCH_TYPES.ROUTE_COVERAGE,
      alwaysRun: true,
      pre: self.injectContrastMiddleware.bind(self),
      post: (wrapCtx) => {
        agentEmitter.emit(EVENTS.APP_USE, self, wrapCtx);
      },
    });

    LC_HTTP_VERBS.forEach((verb) => {
      patcher.patch(express.application, verb, {
        alwaysRun: true,
        name: 'express.application',
        patchType: PATCH_TYPES.ROUTE_COVERAGE,
        pre: self.injectContrastMiddleware.bind(self),
        post: (wrapCtx) => {
          agentEmitter.emit(EVENTS.APP_METHOD, self, wrapCtx, verb);
        },
      });
    });

    patcher.patch(express.application, 'all', {
      alwaysRun: true,
      name: 'express.application',
      patchType: PATCH_TYPES.ROUTE_COVERAGE,
      pre: self.injectContrastMiddleware.bind(self),
      post: (wrapCtx) => {
        agentEmitter.emit(EVENTS.APP_METHOD, self, wrapCtx, 'all');
      },
    });

    agentEmitter.on(HTTP_EVENTS.SERVER_CREATE, this.onServerCreate.bind(this));
    agentEmitter.on(HTTP_EVENTS.SERVER_LISTEN, this.onServerListen.bind(this));
  }

  hookBodyParser(bodyParser) {
    const instrumentation = this;
    const origBodyParser = bodyParser;

    const { json: origJson, raw: origRaw, text: origText, urlencoded: origUrlencoded } = bodyParser;
    const fnArr = [
      {
        key: 'json',
        original: origJson,
      },
      {
        key: 'raw',
        original: origRaw,
      },
      {
        key: 'text',
        original: origText,
      },
      {
        key: 'urlencoded',
        original: origUrlencoded,
      }
    ];

    bodyParser = function bodyParser(...args) {
      const parser = origBodyParser(...args);
      const hookedParser = function(req, res, next) {
        parser(req, res, instrumentation.contrastNext(req, res, next, 'bodyParser'));
      };

      Object.defineProperty(hookedParser, 'name', {
        value: 'bodyParser'
      });

      return hookedParser;
    };

    fnArr.forEach((fn) => {
      const fnName = `bodyParser.${fn.key}`;
      function contrastHooked(...args) {
        const parser = fn.original(...args);
        const hookedParser = function (req, res, next) {
          parser(req, res, instrumentation.contrastNext(req, res, next, fnName));
        };

        Object.defineProperty(hookedParser, 'name', {
          value: `${fn.key}Parser`
        });

        return hookedParser;
      }

      Object.defineProperty(bodyParser, fn.key, {
        configurable: true,
        enumerable: true,
        get: () => contrastHooked,
      });
    });

    return bodyParser;
  }

  contrastNext(req, res, origNext, fnName) {
    return function next() {
      if (fnName == 'bodyParser.json') {
        agentEmitter.emit(EVENTS.BODY_PARSED, req, res, INPUT_TYPES.JSON_VALUE);
      } else {
        agentEmitter.emit(EVENTS.BODY_PARSED, req, res, INPUT_TYPES.BODY);
      }

      origNext();
    };
  }

  onServerListen(args, server) {
    if (!this.serversSeen.has(server)) {
      logger.debug('ignore server.listen: non-express handler');
      return;
    }

    const { app, instrumented } = this.serversSeen.get(server);

    if (!app || !app.defaultConfiguration) {
      logger.error(
        'non-express application mistakenly registered',
        new Error().stack,
      );
      return;
    }

    if (instrumented) {
      logger.debug('express application already instrumented');
      return;
    } else {
      this.serversSeen.get(server).instrumented = true;
    }

    // Patch this instance of express application _once_.
    logger.debug('instrument express application handler');
    this.applyPatches(app);

    logger.debug('weaving custom error handlers');
    this.weaveErrorHandlers(app);

    const stack = ExpressFramework.getStack(app);
    agentEmitter.emit(EVENTS.SERVER_LISTEN, this, stack);
  }

  onServerCreate(app, server) {
    // FIXME(CONTRAST-28927)
    // Do more to make sure we are express.
    if (!app || !app.defaultConfiguration) {
      logger.debug('skip registration: handler not recognized as express');
      return;
    }

    if (this.appsSeen.has(app)) {
      logger.debug('skip registration: express handler already registered');
    } else {
      logger.debug('express handler registered for instrumentation');
      // Associate the server with the express app, and vise versa.
      this.serversSeen.set(server, { app, instrumented: false });
      this.appsSeen.add(app);
    }
  }

  /**
   * Recursively inject our error handler at the location of
   * errorHandlerIndex of each stack.
   * @param {app|router} router App, Routers, Route - something w/ stack
   */
  weaveErrorHandlers(router) {
    const stack = ExpressFramework.getStack(router);
    if (!stack || !router.use || visitedRouters.has(router)) {
      return;
    }
    visitedRouters.add(router);
    const index = ExpressFramework.getErrorHandlerIndex(stack);
    this.useAtPosition(this.errorHandler, index, router);
    // recurse for router layers
    stack
      .filter((layer) => layer.handle.name === 'router')
      .forEach((layer) => this.weaveErrorHandlers(layer.handle));
  }

  createMiddlewareWatchers() {
    this.useAfter(function ContrastRequestReady(req, res, next) {
      setFrameworkRequest(req, ExpressRequest);

      agentEmitter.emit(
        EVENTS.REQUEST_READY,
        req,
        res,
        INPUT_TYPES.QUERYSTRING,
      );

      next();
    }, 'expressInit');

    // ... multer(multi-part form uploads) ...........................
    this.useAfter(function ContrastMultiPartParsed(req, res, next) {
      agentEmitter.emit(EVENTS.BODY_PARSED, req, res, INPUT_TYPES.BODY);
      next();
    }, 'multerMiddleware');

    // ... cookie-parser .............................................
    this.useAfter(function ContrastCookiesParsed(req, res, next) {
      agentEmitter.emit(
        EVENTS.COOKIES_PARSED,
        req,
        res,
        INPUT_TYPES.COOKIE_VALUE,
      );

      next();
    }, 'cookieParser');
  }

  useAtPosition(fn, pos, iUse) {
    if (pos < 0) {
      return;
    }
    const stack = ExpressFramework.getStack(iUse);
    iUse.use(fn);
    this.moveInStack(stack.length - 1, pos, stack);
  }

  moveInStack(from, to, stack) {
    stack.splice(to, 0, stack.splice(from, 1)[0]);
  }

  getPath({ req, layer }) {
    // layer.route.path is set for routes - return that if
    // available.  For middleware functions we add
    // a symbol to the path - return that in that case
    return _.get(layer, 'route.path') || layer[MW_PATH];
  }

  applyPatches(app) {
    const self = this;

    // Hook the request handler so that we can access the top of each route.
    // This is the only place the "params" hash is available
    const Layer = ExpressFramework.getStack(app)[0].constructor;
    const _handle = Layer.prototype.handle_request;
    if (_handle) {
      patcher.patch(Layer.prototype, 'handle_request', {
        name: 'express.Layer.prototype.handle_request',
        patchType: PATCH_TYPES.SOURCE,
        pre(data) {
          const [req, res, next] = data.args;
          if (!req[LAYER_STACK]) {
            req[LAYER_STACK] = [];
          }
          const path = self.getPath({ req, layer: this });
          req[LAYER_STACK].push(path);
          data.args[2] = patcher.patch(next, {
            name: 'express.next',
            patchType: PATCH_TYPES.SOURCE,
            pre(data) {
              req[LAYER_STACK].pop();
            },
          });
          if (req.query) {
            decorateRequest({ query: req.query });
          }
          const params = new Object(this.params);
          if (Object.keys(params).length) {
            agentEmitter.emit(
              EVENTS.PARAMS_PARSED,
              req,
              res,
              INPUT_TYPES.URL_PARAMETER,
            );

            decorateRequest({
              parameters: req.params,
            });
          }

          /*
            Due to some sort of ordering issue in how our use after hooks are applied,
            we're not getting the decoration of the request body in Kraken for free by
            reusing what we do in Express. Here we are ensuring that it will be
            available in the async storage if it is present on the body.

            Whatever the core issue is, it doesn't appear to have any effects
            elsewhere in any of our Express/Kraken framework support.
           */
          if (req.body) {
            decorateRequest({ body: req.body });
          }
        },
      });
    }
  }

  useAfter(fn, after) {
    this.handlers[after] = {
      post: fn,
    };
  }

  static getErrorHandlerIndex(stack) {
    let i = stack.length - 1;
    // Error handlers have length 4 and go at end of stack.
    for (i; 0 < i; i--) {
      if (stack[i].handle.length !== 4) {
        // Go in front of the first one above,
        // return i;
        return i + 1;
      }
    }
    // or, if there are none it should be at the end.
    return stack.length;
  }

  static ContrastErrorHandler(err, req, res, next) {
    if (!agentEmitter.handleError(err, res)) {
      next(err);
    }
  }

  /**
   * Tries to find the proper stack, either on app.stack
   * or app._router.stack. If there is not a stack it will call
   * app.lazyrouter(https://github.com/expressjs/express/blob/master/lib/application.js#L137)
   * and recurse at most another time
   *
   * @param {Object} appOrRouter application or Router instance of express
   * @param iteration {Int} [iteration=1] iteration of function execution
   *
   */
  static getStack(appOrRouter, iteration = 1) {
    // bail after trying twice
    if (iteration > 2) {
      return;
    }

    const stack = appOrRouter.stack || _.get(appOrRouter, '_router.stack');
    if (!stack && appOrRouter.lazyrouter) {
      appOrRouter.lazyrouter();
    }
    return stack || ExpressFramework.getStack(appOrRouter, ++iteration);
  }
}

module.exports = ExpressFramework;
