ForestAdmin/forest-express

View on GitHub
src/index.js

Summary

Maintainability
D
2 days
Test Coverage
B
86%
const _ = require('lodash');
const crypto = require('crypto');
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const { expressjwt: jwt } = require('express-jwt');
const url = require('url');
const requireAll = require('require-all');
const { init, inject } = require('@forestadmin/context');
const serviceBuilder = require('./context/service-builder');

init(serviceBuilder);

const auth = require('./services/auth');

const ResourcesRoutes = require('./routes/resources');
const ActionsRoutes = require('./routes/actions');
const AssociationsRoutes = require('./routes/associations');
const StatRoutes = require('./routes/stats');
const ForestRoutes = require('./routes/forest');
const HealthCheckRoute = require('./routes/healthcheck');
const initScopeRoutes = require('./routes/scopes');
const Schemas = require('./generators/schemas');
const SchemaSerializer = require('./serializers/schema');
const Integrator = require('./integrations');
const { getJWTConfiguration } = require('./config/jwt');
const initAuthenticationRoutes = require('./routes/authentication');

const {
  logger,
  path,
  pathService,
  errorHandler,
  ipWhitelist,
  apimapFieldsFormater,
  apimapSender,
  schemaFileUpdater,
  configStore,
  modelsManager,
  fs,
  tokenService,
  scopeManager,
  smartActionFieldValidator,
} = inject();

const PUBLIC_ROUTES = [
  '/',
  '/healthcheck',
  ...initAuthenticationRoutes.PUBLIC_ROUTES,
];

const ENVIRONMENT_DEVELOPMENT = !process.env.NODE_ENV
  || ['dev', 'development'].includes(process.env.NODE_ENV);
const DISABLE_AUTO_SCHEMA_APPLY = process.env.FOREST_DISABLE_AUTO_SCHEMA_APPLY
  && JSON.parse(process.env.FOREST_DISABLE_AUTO_SCHEMA_APPLY);

let jwtAuthenticator;
let app = null;

function loadCollections(collectionsDir) {
  const isJavascriptOrTypescriptFileName = (fileName) =>
    fileName.endsWith('.js') || (fileName.endsWith('.ts') && !fileName.endsWith('.d.ts'));

  // NOTICE: Ends with `.spec.js`, `.spec.ts`, `.test.js` or `.test.ts`.
  const isTestFileName = (fileName) => fileName.match(/(?:\.test|\.spec)\.(?:js||ts)$/g);

  requireAll({
    dirname: collectionsDir,
    excludeDirs: /^__tests__$/,
    filter: (fileName) =>
      isJavascriptOrTypescriptFileName(fileName) && !isTestFileName(fileName),
    recursive: true,
  });
}

async function buildSchema() {
  const { lianaOptions, Implementation } = configStore;
  const models = Object.values(modelsManager.getModels());
  configStore.integrator = new Integrator(lianaOptions, Implementation);
  await Schemas.perform(
    Implementation,
    configStore.integrator,
    models,
    lianaOptions,
  );
  return models;
}

exports.Schemas = Schemas;
exports.logger = logger;
exports.scopeManager = scopeManager;
exports.ResourcesRoute = {};

/**
 * @param {import('express').Request} request
 * @param {import('express').Response} response
 * @param {import('express').NextFunction} next
 */
exports.ensureAuthenticated = (request, response, next) => {
  const parsedUrl = url.parse(request.originalUrl);
  const forestPublicRoutes = PUBLIC_ROUTES.map((route) => `/forest${route}`);

  if (forestPublicRoutes.includes(parsedUrl.pathname)) {
    next();
    return;
  }

  auth.authenticate(request, response, next, jwtAuthenticator);
};

async function generateAndSendSchema(envSecret) {
  const collections = _.values(Schemas.schemas);
  configStore.integrator.defineCollections(collections);

  collections
    .filter((collection) => collection.actions && collection.actions.length)
    // NOTICE: Check each Smart Action declaration to detect configuration errors.
    .forEach((collection) => {
      collection.actions.forEach((action) => {
        if (!action.name) {
          logger.warn(`An unnamed Smart Action of collection "${collection.name}" has been ignored.`);
        } else {
          try {
            smartActionFieldValidator.validateSmartActionFields(action, collection.name);
          } catch (error) {
            logger.error(error.message);
          }
        }
      });
      // NOTICE: Ignore actions without a name.
      collection.actions = collection.actions.filter((action) => action.name);
    });

  const schemaSerializer = new SchemaSerializer();
  const { options: serializerOptions } = schemaSerializer;
  let collectionsSent;
  let metaSent;

  const pathSchemaFile = path.join(configStore.schemaDir, '.forestadmin-schema.json');

  if (ENVIRONMENT_DEVELOPMENT) {
    const meta = {
      liana: configStore.Implementation.getLianaName(),
      liana_version: configStore.Implementation.getLianaVersion(),
      stack: {
        database_type: configStore.Implementation.getDatabaseType(),
        engine: 'nodejs',
        engine_version: process.versions && process.versions.node,
        orm_version: configStore.Implementation.getOrmVersion(),
      },
    };
    const content = schemaFileUpdater.update(pathSchemaFile, collections, meta, serializerOptions);
    collectionsSent = content.collections;
    metaSent = content.meta;
  } else {
    logger.warn('NODE_ENV is not set to "development", the schema file will not be updated.');
    logger.info('Loading the current version of .forestadmin-schema.json fileā€¦');

    try {
      const content = fs.readFileSync(pathSchemaFile);
      if (!content) {
        logger.error('The .forestadmin-schema.json file is empty.');
        logger.error('The schema cannot be synchronized with Forest Admin servers.');
        return null;
      }
      const contentParsed = JSON.parse(content.toString());
      collectionsSent = contentParsed.collections;
      metaSent = contentParsed.meta;
    } catch (error) {
      if (error.code === 'ENOENT') {
        logger.error('The .forestadmin-schema.json file does not exist.');
      } else {
        logger.error('The content of .forestadmin-schema.json file is not a correct JSON.');
      }
      logger.error('The schema cannot be synchronized with Forest Admin servers.');
      return null;
    }
  }

  if (DISABLE_AUTO_SCHEMA_APPLY) {
    logger.warn('FOREST_DISABLE_AUTO_SCHEMA_APPLY is set to true, the schema file will not be sent to Forest.');
    return Promise.resolve();
  }

  const schemaSent = schemaSerializer.perform(collectionsSent, metaSent);

  const hash = crypto.createHash('sha1');
  const schemaFileHash = hash.update(JSON.stringify(schemaSent)).digest('hex');
  schemaSent.meta.schemaFileHash = schemaFileHash;

  logger.info('Checking need for apimap update...');
  return apimapSender.checkHash(envSecret, schemaFileHash)
    .then(({ body }) => {
      if (body.sendSchema) {
        logger.info('Sending schema file to Forest...');
        return apimapSender.send(envSecret, schemaSent)
          .then((result) => {
            logger.info('Schema file sent.');
            return result;
          });
      }
      logger.info('No change in apimap, nothing sent to Forest.');
      return Promise.resolve(null);
    });
}

const reportSchemaComputeError = (error) => {
  logger.error('An error occurred while computing the Forest schema. Your application schema cannot be synchronized with Forest. Your admin panel might not reflect your application models definition. ', error);
};

exports.init = async (Implementation) => {
  const { opts } = Implementation;

  configStore.Implementation = Implementation;
  configStore.lianaOptions = opts;
  // Trick to update ForestAdminClient options at runtime
  const { options } = inject().forestAdminClient;

  if (configStore.lianaOptions.envSecret) {
    options.envSecret = configStore.lianaOptions.envSecret;
  }

  if (app) {
    logger.warn('Forest init function called more than once. Only the first call has been processed.');
    return app;
  }

  app = express();

  try {
    configStore.validateOptions();
  } catch (error) {
    logger.error(error.message);
    return Promise.resolve(app);
  }

  const pathMounted = pathService.generateForInit('*', configStore.lianaOptions);

  auth.initAuth(configStore.lianaOptions);

  // CORS
  let allowedOrigins = ['localhost:4200', /\.forestadmin\.com$/];
  const oneDayInSeconds = 86400;

  if (process.env.CORS_ORIGINS) {
    allowedOrigins = allowedOrigins.concat(process.env.CORS_ORIGINS.split(','));
  }

  const corsOptions = {
    origin: allowedOrigins,
    maxAge: oneDayInSeconds,
    credentials: true,
    preflightContinue: true,
  };

  // Support for request-private-network as the `cors` package
  // doesn't support it by default
  // See: https://github.com/expressjs/cors/issues/236
  app.use(pathMounted, (req, res, next) => {
    if (req.headers['access-control-request-private-network']) {
      res.setHeader('access-control-allow-private-network', 'true');
    }
    next(null);
  });

  app.use(pathService.generate(initAuthenticationRoutes.CALLBACK_ROUTE, opts), cors({
    ...corsOptions,
    // this route needs to be called after a redirection
    // in this situation, the origin sent by the browser is "null"
    origin: ['null', ...corsOptions.origin],
  }));

  app.use(pathMounted, cors(corsOptions));

  // Mime type
  app.use(pathMounted, bodyParser.json());

  // Authentication
  if (configStore.lianaOptions.authSecret) {
    jwtAuthenticator = jwt(getJWTConfiguration({
      secret: configStore.lianaOptions.authSecret,
      getToken: (request) => {
        if (request.headers) {
          if (request.headers.authorization
            && request.headers.authorization.split(' ')[0] === 'Bearer') {
            return request.headers.authorization.split(' ')[1];
          }
          // NOTICE: Necessary for downloads authentication.
          if (request.headers.cookie) {
            const forestSessionToken = tokenService
              .extractForestSessionToken(request.headers.cookie);
            if (forestSessionToken) {
              return forestSessionToken;
            }
          }
        }
        return null;
      },
      requestProperty: 'user',
    }));
  }

  if (jwtAuthenticator) {
    const pathsPublic = [/^\/forest\/authentication$/, /^\/forest\/authentication\/.*$/];
    app.use(pathMounted, jwtAuthenticator.unless({ path: pathsPublic }));
  }

  new HealthCheckRoute(app, configStore.lianaOptions).perform();
  initScopeRoutes(app, inject());
  initAuthenticationRoutes(app, configStore.lianaOptions, inject());

  // Init
  try {
    const models = await buildSchema();

    if (configStore.doesConfigDirExist()) {
      loadCollections(configStore.configDir);
    }

    if (configStore?.Implementation?.Flattener) {
      app.use(configStore.Implementation.Flattener.requestUnflattener);
    }

    models.forEach((model) => {
      const modelName = configStore.Implementation.getModelName(model);

      configStore.integrator.defineRoutes(app, model, configStore.Implementation);

      const resourcesRoute = new ResourcesRoutes(app, model);
      resourcesRoute.perform();
      exports.ResourcesRoute[modelName] = resourcesRoute;

      new AssociationsRoutes(
        app,
        model,
        configStore.Implementation,
        configStore.integrator,
        configStore.lianaOptions,
      ).perform();
      new StatRoutes(
        app,
        model,
        configStore.Implementation,
        configStore.lianaOptions,
      ).perform();
    });

    const collections = _.values(Schemas.schemas);
    collections.forEach((collection) => {
      const retrievedModel = models.find((model) =>
        configStore.Implementation.getModelName(model) === collection.name);
      new ActionsRoutes().perform(
        app,
        collection,
        retrievedModel,
        configStore.Implementation,
        configStore.lianaOptions,
        auth,
      );
    });

    new ForestRoutes(app, configStore.lianaOptions).perform();

    app.use(pathMounted, errorHandler({ logger }));

    const generateAndSendSchemaPromise = generateAndSendSchema(configStore.lianaOptions.envSecret)
      .catch((error) => {
        reportSchemaComputeError(error);
      });
    // NOTICE: Hide promise for testing purpose. Waiting here in production
    //         will change app behaviour.
    if (process.env.NODE_ENV === 'test') {
      app._generateAndSendSchemaPromise = generateAndSendSchemaPromise;
    }

    try {
      await ipWhitelist.retrieve(configStore.lianaOptions.envSecret);
    } catch (error) {
      // NOTICE: An error log (done by the service) is enough in case of retrieval error.
    }

    if (configStore.lianaOptions.expressParentApp) {
      configStore.lianaOptions.expressParentApp.use('/forest', app);
    }

    return app;
  } catch (error) {
    reportSchemaComputeError(error);
    throw error;
  }
};

function getSmartActionsUsingHTTPMethod(actions) {
  return actions.filter(
    (action) => Object.hasOwnProperty.call(action, 'httpMethod'),
  );
}

exports.collection = (name, opts) => {
  if (_.isEmpty(Schemas.schemas) && opts.modelsDir) {
    logger.error(`Cannot customize your collection named "${name}" properly. Did you call the "collection" method in the /forest directory?`);
    return;
  }

  let collection = _.find(Schemas.schemas, { name });

  if (!collection) {
    collection = _.find(Schemas.schemas, { nameOld: name });
    if (collection) {
      name = collection.name;
      logger.warn(`DEPRECATION WARNING: Collection names are now based on the models names. Please rename the collection "${collection.nameOld}" of your Forest customisation in "${collection.name}".`);
    }
  }

  if (collection) {
    if (!Schemas.schemas[name].actions) { Schemas.schemas[name].actions = []; }
    if (!Schemas.schemas[name].segments) { Schemas.schemas[name].segments = []; }

    Schemas.schemas[name].actions = _.union(opts.actions, Schemas.schemas[name].actions);
    Schemas.schemas[name].segments = _.union(opts.segments, Schemas.schemas[name].segments);

    // NOTICE: `httpMethod` on smart actions will be removed in the future.
    getSmartActionsUsingHTTPMethod(Schemas.schemas[name].actions)
      .forEach((action) => {
        const removeHttpMethodMessage = 'Please update your smart action route to use the POST verb instead, and remove the "httpMethod" property in your forest file.';

        if (['get', 'head'].includes(action.httpMethod.toLowerCase())) {
          logger.error(`The "httpMethod" ${action.httpMethod} of your smart action "${action.name}" is not supported. ${removeHttpMethodMessage}`);
        } else {
          logger.warn(`DEPRECATION WARNING: The "httpMethod" property of your smart action "${action.name}" is now deprecated. ${removeHttpMethodMessage}`);
        }
      });

    // NOTICE: Smart Field definition case
    opts.fields = apimapFieldsFormater.formatFieldsByCollectionName(opts.fields, name);
    Schemas.schemas[name].fields = _.concat(opts.fields, Schemas.schemas[name].fields);

    if (configStore?.Implementation?.Flattener) {
      const Flattener = new configStore.Implementation.Flattener(
        Schemas.schemas[name],
        opts.fieldsToFlatten,
        modelsManager.getModelByName(name),
        configStore.lianaOptions,
      );
      Flattener.flattenFields();
    }

    if (opts.searchFields) {
      Schemas.schemas[name].searchFields = opts.searchFields;
    }
  } else if (opts.fields && opts.fields.length) {
    // NOTICE: Smart Collection definition case
    opts.name = name;
    opts.idField = 'id';
    opts.isVirtual = true;
    opts.isSearchable = !!opts.isSearchable;
    opts.fields = apimapFieldsFormater.formatFieldsByCollectionName(opts.fields, name);
    Schemas.schemas[name] = opts;
  }
};

exports.SchemaSerializer = SchemaSerializer;
exports.StatSerializer = require('./serializers/stat');
exports.ResourceSerializer = require('./serializers/resource');
exports.ResourceDeserializer = require('./deserializers/resource');
exports.BaseFiltersParser = require('./services/base-filters-parser');
exports.BaseOperatorDateParser = require('./services/base-operator-date-parser');
exports.SchemaUtils = require('./utils/schema');

exports.RecordsGetter = require('./services/exposed/records-getter');
exports.RecordsCounter = require('./services/exposed/records-counter').default;
exports.RecordsExporter = require('./services/exposed/records-exporter');
exports.RecordGetter = require('./services/exposed/record-getter');
exports.RecordUpdater = require('./services/exposed/record-updater');
exports.RecordCreator = require('./services/exposed/record-creator');
exports.RecordRemover = require('./services/exposed/record-remover');
exports.RecordsRemover = require('./services/exposed/records-remover');
exports.RecordSerializer = require('./services/exposed/record-serializer');
exports.ScopeManager = require('./services/scope-manager');
exports.PermissionMiddlewareCreator = require('./middlewares/permissions');
exports.deactivateCountMiddleware = require('./middlewares/count');

exports.errorHandler = errorHandler;

exports.PUBLIC_ROUTES = PUBLIC_ROUTES;

if (process.env.NODE_ENV === 'test') {
  exports.generateAndSendSchema = generateAndSendSchema;
}