Home Intro Source Mebo GitHub

src/Readers/WebRequest.js

const os = require('os');
const fs = require('fs');
const util = require('util');
const path = require('path');
const assert = require('assert');
const formidable = require('formidable');
const TypeCheck = require('js-typecheck');
const ejs = require('ejs');
const Settings = require('../Settings');
const Inputs = require('../Inputs');
const Handler = require('../Handler');
const Reader = require('../Reader');
const Utils = require('../Utils');
const MeboError = require('../MeboError');
const MeboErrors = require('../MeboErrors');

// promisifying
const mkdtemp = util.promisify(fs.mkdtemp);
const rename = util.promisify(fs.rename);
const stat = util.promisify(fs.stat);
const rmdir = util.promisify(fs.rmdir);
const unlink = util.promisify(fs.unlink);
const readFile = util.promisify(fs.readFile);

// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _temporaryFolders = Symbol('temporaryFolders');
const _temporaryFiles = Symbol('temporaryFiles');
const _request = Symbol('request');
const _checkedUploadDirectories = Symbol('checkedUploadDirectories');

/**
 * Web request reader.
 *
 * This reader is used by the {@link Web} handler to query values from a request.
 *
 * This reader supports all serializable inputs. It deals with file uploads
 * automatically; therefore any {@link FilePath} input becomes a potential
 * upload field. When that is the case the input gets assigned with the file path
 * about where the file has been uploaded to. By default it tries to keep the original
 * uploaded file name by replacing any illegal character with underscore, however you can
 * control this behavior via `uploadPreserveName` (if disabled each uploaded file
 * gets named with an unique name).
 *
 * This reader works by looking for the input names in the request, for instance:
 *
 * `http://.../?myInput=10&myOtherInput=20`
 *
 * ```
 * class MyAction extends Mebo.Action {
 *   constructor(){
 *     super();
 *     this.createInput('myInput: numeric');
 *     this.createInput('myOtherInput: numeric');
 *   }
 * }
 * ```
 *
 * When a value is found for the input, it gets loaded via {@link Input.parseValue}
 * where each input implementation has its own way of parsing the serialized data,
 * to find out about how a value is serialized for an specific input type you could simply
 * set an arbitrary value to the input you are interested then query it back through
 * {@link Input.serializeValue}. Also, Mebo provides a reference datasheet
 * about the serialization forms for the inputs bundled with it, found at {@link Reader}.
 *
 * As supported for the cli handler (--help/-h) you can also query a basic help page
 * for any action through the web handler by including: `help` to the query string:
 * `http://.../?help`
 * By doing that it renders a page with the description of the action and helpful
 * information about the inputs. This setting is available through `handler/web/allowHelp`
 * (default true).
 *
 * You can define the description displayed in the help of each input by
 * setting the input's property `description`. Also, the description for the action
 * itself can be defined by setting the action's metadata `description`.
 *
 * **Route parameters:**
 * If an webfied action contains route parameters defined (`/users/:userId/books/:bookId`)
 * this reader is going to try to find them under the action input names.
 * Therefore when a route parameter matches to the name of an input then the value of
 * the parameter is loaded to the input.
 *
 * **Vector Inputs:**
 * Supported conventions for array parameters:
 *
 * - *Serialized vector value (JSON Style)*
 * ```
 * http://.../?vectorInput=["a", "b", "c"]
 * ```
 *
 * - *Repeated param names*
 * ```
 * http://.../?vectorInput[]=a&vectorInput[]=b&vectorInput[]=c
 * ```
 * *or*
 * ```
 * http://.../?vectorInput=a&vectorInput=b&vectorInput=c
 * ```
 *
 * <h2>Options Summary</h2>
 *
 * Option Name | Description
 * --- | ---
 * uploadDirectory | directory used for placing file uploads in, default value\
 * (`TMP_DIR/upload`) driven by:\
 * <br>`Settings.get('reader/webRequest/uploadDirectory')`
 * uploadPreserveName | enabled by default it tries to keep the original final name of \
 * uploads, any illegal character is replaced by underscore, otherwise if disabled \
 * it gives a random name to the upload, default value driven by: \
 * <br>`Settings.get('reader/webRequest/uploadPreserveName')`
 * uploadDefaultMaxFileSize | total maximum file size about all uploads in bytes, \
 * default value (`4 mb`) driven by: \
 * <br>`Settings.get('reader/webRequest/uploadDefaultMaxFileSize')`
 * maxFields | Limits the number of fields that the querystring parser will decode, \
 * default value (`1000`) driven by: \
 * <br>`Settings.get('reader/webRequest/maxFields')`
 * maxFieldsSize | Limits the amount of memory all fields together (except files) can\
 * allocate in bytes, default value (`2 mb`) driven by:\
 * <br>`Settings.get('reader/webRequest/maxFieldsSize')` [`2 mb`]
 *
 * <br/>Example about defining a custom `uploadDefaultMaxFileSize` option from inside of an
 * action through the metadata support:
 *
 * ```
 * class MyAction extends Mebo.Action{
 *    constructor(){
 *      super();
 *
 *      // 'uploadDefaultMaxFileSize' option
 *      this.setMeta('handler.web.readOptions', {
 *        uploadDefaultMaxFileSize: 10 * 1024 * 1024,
 *      });
 *    }
 * }
 * ```
 */
class WebRequest extends Reader{

  /**
   * Creates a web request reader
   *
   * @param {Action} action - action that should be used by the reader
   * @param {Object} req - request object created by express-js
   */
  constructor(action, req){
    super(action);

    this._setRequest(req);

    // default options
    this.setOption('uploadDirectory', Settings.get('reader/webRequest/uploadDirectory'));
    this.setOption('uploadPreserveName', Settings.get('reader/webRequest/uploadPreserveName'));
    this.setOption('uploadDefaultMaxFileSize', Settings.get('reader/webRequest/uploadDefaultMaxFileSize'));
    this.setOption('maxFields', Settings.get('reader/webRequest/maxFields'));
    this.setOption('maxFieldsSize', Settings.get('reader/webRequest/maxFieldsSize'));

    this[_temporaryFolders] = [];
    this[_temporaryFiles] = [];
  }

  /**
   * Returns the request object created by express
   *
   * @return {Object}
   * @see http://expressjs.com/en/api.html#req
   */
  request(){
    return this[_request];
  }

  /**
   * Implements the web request reader
   *
   * @param {Array<Input>} inputList - Valid list of inputs that should be used for
   * the parsing
   * @return {Promise<Object>}
   * @protected
   */
  async _perform(inputList){
    const result = {};
    const request = this.request();
    const test = new Map();

    // when help is requested
    if (Settings.get('handler/web/allowHelp') && 'help' in request.query){
      throw new MeboErrors.Help(await this._renderHelp(inputList));
    }

    // handling body fields
    let bodyFields = null;
    if (['POST', 'PUT', 'PATCH'].includes(request.method)){
      bodyFields = await this._bodyFields(inputList);
    }

    // setting the action inputs based on the request parameters
    for (const input of inputList){
      const inputName = input.name();

      // value set by the request
      let requestInputValue;
      const restrictWebAccess = input.hasProperty('restrictWebAccess') ? input.property('restrictWebAccess') : false;

      // mapping param to input names
      if (!restrictWebAccess && inputName in request.params){
        requestInputValue = request.params[inputName];
      }
      // body fields
      else if (bodyFields !== null){
        if (restrictWebAccess && inputName in bodyFields.files){
          requestInputValue = bodyFields.files[inputName];
        }
        else if (!restrictWebAccess){

          if (inputName in bodyFields.files){
            requestInputValue = bodyFields.files[inputName];
          }
          else if (inputName in bodyFields.fields){
            requestInputValue = bodyFields.fields[inputName];
          }
        }

        // in case of a vector input when just a single file has been uploaded we need to
        // make the value from a scalar to vector one
        if (input.isVector() && inputName in bodyFields.files && !TypeCheck.isList(requestInputValue)){
          requestInputValue = [requestInputValue];
        }
      }

      // GET, DELETE ...
      else if (!restrictWebAccess && inputName in request.query){
        requestInputValue = request.query[inputName];
      }

      if (requestInputValue !== undefined){

        // reading buffer data
        if (input instanceof Inputs.Buf){
          // creating a promise that is later executed to retrieve the buffer
          // from the uploaded file
          test.set(inputName, readFile(requestInputValue));

          // marking for removal temporary file used by the buffer input
          this[_temporaryFiles].push(requestInputValue);
        }

        result[inputName] = requestInputValue;
      }
    }

    // retrieving the value from the buffer inputs
    const bufferPromisesResult = await Promise.all(test.values());
    let currentIndex = 0;
    for (const bufferInputName of test.keys()){
      result[bufferInputName] = bufferPromisesResult[currentIndex];
      currentIndex++;
    }

    return result;
  }

  /**
   * Computes the output displayed as help
   *
   * @param {Array<Input>} inputList - Valid list of inputs
   * @return {Promise<string>}
   * @protected
   */
  async _renderHelp(inputList){

    const inputData = [];

    /* eslint-disable no-await-in-loop */
    for (const input of inputList){
      // computing description
      let inputDescription = await input.property('description');
      if (TypeCheck.isNone(inputDescription)){
        inputDescription = '';
      }

      // querying any type hint defined for the input
      let webTypeHint = '';
      if (input.hasProperty('webTypeHint')){
        webTypeHint = input.property('webTypeHint');
      }

      // input data passed to the help
      inputData.push({
        name: input.name(),
        type: input.property('type'),
        required: input.isRequired() && input.isEmpty(),
        vector: input.isVector(),
        description: inputDescription,
        typeHint: webTypeHint,
      });
    }

    const data = {
      method: this.request().method,
      routePath: this.request().route.path,
      description: this.action().meta('description', ''),
      inputs: inputData,
    };

    return ejs.renderFile(
      Settings.get('reader/webRequest/helpTemplate'),
      data,
    );
  }

  /**
   * Sets the request object created by express
   *
   * @param {Object} value - req object
   * @see http://expressjs.com/en/api.html#req
   * @private
   */
  _setRequest(value){
    assert(TypeCheck.isObject(value) && value.method, 'Invalid request object');

    this[_request] = value;
  }

  /**
   * Returns an object containing the processed body fields parsed, this object separates
   * the fields from the files
   *
   * @param {Array<Input>} inputList - Valid list of inputs that should be used for
   * the parsing
   * @return {Promise<Object>}
   * @private
   */
  async _bodyFields(inputList){

    // making sure the upload directory exists
    const uploadDirectory = this.option('uploadDirectory');
    if (uploadDirectory && !WebRequest[_checkedUploadDirectories].includes(uploadDirectory)){

      // in case the stat fails it will try to recreate the folders
      let needsToCreate = false;
      try{
        await stat(uploadDirectory);
      }
      // otherwise tries to create it
      catch(err){

        // file not found
        if (err.code === 'ENOENT'){
          needsToCreate = true;
        }
        else{
          /* istanbul ignore next */
          throw err;
        }
      }

      if (needsToCreate){
        await Utils.mkdirs(uploadDirectory);
      }

      WebRequest[_checkedUploadDirectories].push(uploadDirectory);
    }

    // parsing the body fields
    const bodyFields = await this._parseForm(inputList);

    // normalizing multiple values for the fields
    this._normalizeFieldMultipleValues(bodyFields);

    // handling the uploaded files
    await this._handleUploadedFiles(bodyFields);

    return bodyFields;
  }

  /**
   * Normalizing multiple values for the fields by adding the values to an array
   * followed by the name of the field (field=[value1, value2...]) rather than
   * having an individual field entry for each of the indexes of the array
   * (field[0]=value1, field[1]=value2...)
   *
   * @param {Object} bodyFields - parsed body object
   * @private
   */
  _normalizeFieldMultipleValues(bodyFields){

    const multipleValueFields = {};

    for (const inputName in bodyFields.fields){

      // checking if there is any array field if so extracting their names and values
      if (inputName.endsWith(']')){
        const inputParts = inputName.split('[');
        if (inputParts.length === 2){
          if (!(inputParts[0] in multipleValueFields)){
            multipleValueFields[inputParts[0]] = [];
          }

          multipleValueFields[inputParts[0]].push(bodyFields.fields[inputName]);
        }
      }
    }

    // merging the normalized multiple values to the original fields
    Object.assign(bodyFields.fields, multipleValueFields);
  }

  /**
   * Handles the uploaded files (changes bodyFields in-place)
   *
   * @param {Object} bodyFields - parsed body object
   * @private
   */
  async _handleUploadedFiles(bodyFields){
    const keepOrignalNamePromises = new Map();

    const preserveFileName = this.option('uploadPreserveName');
    for (const inputName in bodyFields.files){
      // multiple files
      if (TypeCheck.isList(bodyFields.files[inputName])){

        for (const index in bodyFields.files[inputName]){
          if (preserveFileName){
            keepOrignalNamePromises.set([inputName, index], this._keepOriginalUploadName(bodyFields.files[inputName][index]));
          }
          else{
            bodyFields.files[inputName][index] = bodyFields.files[inputName][index].path;
          }
        }
      }

      // single file
      else{
        if (preserveFileName){
          keepOrignalNamePromises.set([inputName], this._keepOriginalUploadName(bodyFields.files[inputName]));
        }
        else{
          bodyFields.files[inputName] = bodyFields.files[inputName].path;
        }
      }
    }

    // 'keep original name' is done in parallel for all files at once
    if (keepOrignalNamePromises.size){
      const originalNameKeys = Array.from(keepOrignalNamePromises.keys());
      const originalNameValues = await Promise.all(keepOrignalNamePromises.values());

      for (let i=0, len=keepOrignalNamePromises.size; i < len; ++i){
        // single
        if (originalNameKeys[i].length === 1){
          bodyFields.files[originalNameKeys[i][0]] = originalNameValues[i];
        }
        // multi
        else{
          bodyFields.files[originalNameKeys[i][0]][originalNameKeys[i][1]] = originalNameValues[i];
        }
      }
    }

    // adding a wrapup to cleanup temporary files used for the uploads
    if (this[_temporaryFolders].length){
      this.action().session().wrapup().addWrappedPromise(
        this._cleanupTemporaryFiles.bind(this),
        {
          priority: 100,
        },
      );
    }

    // adding a wrapup to cleanup temporary folders used for the uploads
    if (this[_temporaryFolders].length){
      this.action().session().wrapup().addWrappedPromise(
        this._cleanupTemporaryFolders.bind(this),
        {
          priority: 1000,
        },
      );
    }
  }

  /**
   * Auxiliary method used to promisify formidable's form.parse call
   *
   * @param {Array<Input>} inputList - Valid list of inputs that should be used for
   * the parsing
   * @return {Promise<Object>}
   * @private
   */
  _parseForm(inputList){
    return new Promise((resolve, reject) => {

      const form = new formidable.IncomingForm();

      // formidable settings
      form.uploadDir = this.option('uploadDirectory');

      // in case the max size has been cranked-up by the inputs
      // to a value that is greater than the option 'uploadDefaultMaxFileSize'
      // we use that value instead
      let maxFileSize = this.option('uploadDefaultMaxFileSize');
      for (const input of inputList){
        // file path input
        if (input instanceof Inputs.FilePath
        && input.hasProperty('maxFileSize')
        && input.property('maxFileSize') > maxFileSize){
          maxFileSize = input.property('maxFileSize');
        }
        // buffer input
        else if (input instanceof Inputs.Buf
        && input.hasProperty('maxBufferSize')
        && input.property('maxBufferSize') > maxFileSize){
          maxFileSize = input.property('maxBufferSize');
        }
      }

      form.maxFileSize = maxFileSize;
      form.keepExtensions = true;
      form.multiples = true;
      form.encoding = 'utf-8';
      form.maxFields = this.option('maxFields');
      form.maxFieldsSize = this.option('maxFieldsSize');

      form.parse(this.request(), (err, formFields, formFiles) => {

        // in case of any error
        /* istanbul ignore next */
        if (err){
          err.status = err.status || 500;

          // converting some of the formidable exceptions
          // to mebo mebo exceptions.
          // formidable does not provide a custom exception type
          // for the exceptions. Therefore, we need to parse
          // the message to know the context.
          if (!(err instanceof MeboError) && err.message.startsWith('maxFileSize exceeded,')){
            reject(new MeboErrors.ValidationFail(err.message));
            return;
          }

          reject(err);
          return;
        }

        const result = {};
        result.files = formFiles;
        result.fields = formFields;

        resolve(result);
      });
    });
  }

  /**
   * Renames the uploaded file names which receive random unique names to the original uploaded file name,
   * this is done by creating an intermediated unique name folder for each of the upload files then
   * renaming them back to the original name.
   * This method is called when `uploadPreserveName` returns true
   *
   * @param {string} uploadFile - input file name
   * @return {string} output file name
   * @private
   */
  async _keepOriginalUploadName(uploadFile){
    const uploadFolder = await mkdtemp(path.join(this.option('uploadDirectory'), 'file-'));
    const finalName = path.join(uploadFolder, uploadFile.name.replace(/[^a-zA-Z0-9 _.-]/g, '_'));
    await rename(uploadFile.path, finalName);

    // temporary folders removed at the end of the request
    this[_temporaryFolders].push(uploadFolder);

    return finalName;
  }

  /**
   * Promise based method that removes the temporary files created during upload
   * (used by buffer inputs)
   *
   * @return {Promise}
   * @private
   */
  _cleanupTemporaryFiles(){
    const result = Promise.all(
      this[_temporaryFiles].map((x) => unlink(x)),
    );

    // theoretically this method can be called multiple times by handler.run
    // for the same request
    this[_temporaryFiles] = [];

    return result;
  }

  /**
   * Promise based method that removes the temporary folders that are created
   * when `uploadPreserveName` is enabled
   *
   * @return {Promise}
   * @private
   */
  _cleanupTemporaryFolders(){
    const result = Promise.all(
      this[_temporaryFolders].map((x) => this._deleteTemporaryFolder(x)),
    );

    // theoretically this method can be called multiple times by handler.run
    // for the same request
    this[_temporaryFolders] = [];

    return result;
  }

  /**
   * Deletes a temporary folder
   *
   * @param {string} folder - folder to be deleted
   * @return {Promise}
   * @private
   */
  _deleteTemporaryFolder(folder){
    return rmdir(folder).then((result) => {
      // runaway promise
      return null;
    }).catch(/* istanbul ignore next */ (err) => {
      if (!(['ENOTEMPTY', 'ENOENT'].includes(err.code))){
        throw err;
      }
    });
  }
}

WebRequest[_checkedUploadDirectories] = [];

// default settings
Settings.set('reader/webRequest/uploadDirectory', path.join(os.tmpdir(), 'upload'));
Settings.set('reader/webRequest/uploadDefaultMaxFileSize', 4 * 1024 * 1024);
Settings.set('reader/webRequest/uploadPreserveName', true);
Settings.set('reader/webRequest/maxFields', 1000);
Settings.set('reader/webRequest/maxFieldsSize', 4 * 1024 * 1024);
Settings.set(
  'reader/webRequest/helpTemplate',
  path.join(path.dirname(path.dirname(path.dirname(__filename))), 'data', 'handlers', 'web', 'help.ejs'),
);

// registering reader
Handler.registerReader(WebRequest, 'web');

module.exports = WebRequest;