Source: routes/handlers/api-upload-handler.js

'use strict';

// External deps
const escapeHTML = require('escape-html');

// Internal deps
const languages = require('../../locales/languages');
const { validateFiles, cleanupFiles, getFileRevs, completeUploads } =
  require('../uploads');
const ReportedError = require('../../util/reported-error');
const File = require('../../models/file');
const api = require('../helpers/api');

/**
 * Process uploads via the API.
 *
 * @namespace APIUploadHandler
 */
module.exports = apiUploadHandler;

 /**
 * The main handler for processing upload attempts via the API. Kicks in after
 * the basic MIME check within the Multer middleware. Handles metadata
 * validation & creation of "File" revisions.
 *
 * @param {IncomingMessage} req
 *  Express request
 * @param {ServerResponse} res
 *  Express response
 * @returns {Function}
 *  callback invoked by the Multer middleware
 * @memberof APIUploadHandler
 */
function apiUploadHandler(req, res) {
  return fileFilterError => {

    // Status code will be used for known errors from the app, not for errors
    // from multer or unknown errors
    const abortUpload = (errors = []) => {
      cleanupFiles(req);
      const errorMessages = errors.map(error => {
        const rv = {};
        rv.internalMessage = error.message;
        if (error instanceof ReportedError)
          rv.displayMessage = req.__(...error.getEscapedUserMessageArray());
        return rv;
      });
      api.error(req, res, errorMessages);
    };

    if (fileFilterError)
      return abortUpload([fileFilterError]);

    if (!req.files.length)
      return abortUpload([new Error('No files received.')]);

    const validationErrors = validateAllMetadata(req.files, req.body);
    if (validationErrors.length)
      return abortUpload(validationErrors);

    validateFiles(req.files)
      .then(fileTypes => getFileRevs(req.files, fileTypes, req.user, ['upload', 'upload-via-api']))
      .then(fileRevs => addMetadata(req.files, fileRevs, req.body))
      .then(completeUploads)
      .then(fileRevs => Promise.all(fileRevs.map(fileRev => fileRev.save())))
      .then(fileRevs => reportUploadSuccess(req, res, fileRevs))
      .catch(error => abortUpload([error]));
  };
}

/**
 * Ensure that required fields have been submitted for each upload.
 *
 * @param {Object[]} files
 *  Files to validate
 * @param {Object} data
 *  Request data
 * @returns {Error[]}
 *  Validation errors, if any.
 * @memberof APIUploadHandler
 */
function validateAllMetadata(files, data) {
  const errors = [],
    processedFields = ['multiple'],
    multiple = Boolean(data.multiple);

  if (files.length > 1 && !multiple)
    errors.push(new Error(`Received more than one file, but 'multiple' flag is not set.`));

  for (let file of files) {
    const validationResult = validateMetadata(file, data, { addSuffix: multiple });
    errors.push(...validationResult.errors);
    processedFields.push(...validationResult.processedFields);
  }
  const remainingFields = Object.keys(data).filter(key => !processedFields.includes(key));
  if (remainingFields.length > 0)
    errors.push(new Error(`Unknown parameter(s): ${remainingFields.join(', ')}`));

  return errors;
}


/**
 * Check that required metadata fields are present for a given upload. Also
 * ensures that language is valid, and that license is one of the accepted
 * licenses.
 *
 * @param {Object} file
 *  File received from the upload middleware
 * @param {Object} data
 *  Request data that should contain the metadata we need
 * @param {Object} [options]
 *  Validation options
 * @param {Boolean} options.addSuffix=false
 *  Add a filename suffix to each field (used for requests with multiple files)
 * @returns {Error[]}
 *  Validation errors for this field, if any
 * @memberof APIUploadHandler
 */
function validateMetadata(file, data, { addSuffix = false } = {}) {
  const validLicenses = File.getValidLicenses();
  const errors = [],
    processedFields = [];
  // For multiple uploads, we use the filename as a suffix for each file
  const field = key => addSuffix ? `${key}-${file.originalname}` : key;
  const ownWork = Boolean(data[field('ownwork')]);

  const required = ownWork ?
    ['description', 'license', 'ownwork', 'language'].map(field) :
    ['description', 'creator', 'source', 'license', 'language'].map(field);

  // We ignore presence/content of these conflicting fields if they are "falsy",
  // otherwise we report an error
  const conditionallyIgnored = ownWork ?
    ['creator', 'source'].map(field) :
    ['ownwork'].map(field);

  errors.push(...checkRequired(data, required, conditionallyIgnored));
  processedFields.push(...required, ...conditionallyIgnored);

  const language = data[field('language')];

  if (language && !languages.isValid(language))
    errors.push(new Error(`Language ${language} is not valid or recognized.`));

  const license = data[field('license')];
  if (license && !validLicenses.includes(license))
    errors.push(new Error(`License ${license} is not one of: ` +
      validLicenses.join(', ')));

  return { errors, processedFields };
}

/**
 * Check if we have "truthy" values for all required fields in a given object
 * (typically an API request body). Also throws errors if given fields are
 * present with a "truthy" value, which is useful for conflicting parameters
 * that may be submitted with empty values.
 *
 * @param {Object} obj
 *  any object whose keys we want to validate
 * @param {String[]} [required=[]]
 *  keys which must access a truthy value
 * @param {String[]} [conditionallyIgnored=[]]
 *  keys which will be ignored _unless_ they access a truthy value
 * @returns {Error[]}
 *  errors for each validation issue or an empty array
 * @memberof APIUploadHandler
 */
function checkRequired(obj, required = [], conditionallyIgnored = []) {
  // Make a copy since we modify it below
  required = required.slice();

  const errors = [];
  for (let key in obj) {
    if (required.includes(key)) {
      if (!obj[key])
        errors.push(new Error(`Parameter must not be empty: ${key}`));
      required.splice(required.indexOf(key), 1);
    }
    if (conditionallyIgnored.includes(key) && Boolean(obj[key]))
      errors.push(new Error(`Parameter must be skipped, be empty, or evaluate to false: ${key}`));

  }
  if (required.length)
    errors.push(new Error(`Missing the following parameter(s): ${required.join(', ')}`));

  return errors;
}


/**
 * Add all metadata to each file revision (does not save)
 *
 * @param {Object[]} files
 *  file objects received from the middleware
 * @param {File[]} fileRevs
 *  initial revisions of the File model, containing only the data that comes
 *  with the file itself (MIME type, filename, etc.)
 * @param {Ojbect} data
 *  request data
 * @returns {File[]}
 *  the revisions for further processing
 * @memberof APIUploadHandler
 */
function addMetadata(files, fileRevs, data) {
  const multiple = Boolean(data.multiple);
  fileRevs.forEach((fileRev, index) =>
    addMetadataToFileRev(files[index], fileRevs[index], data, { addSuffix: multiple })
  );
  return fileRevs;
}


/**
 * Add all relevant metadata to an individual file revision
 *
 * @param {Object} file
 *  file object from the middleware
 * @param {File} fileRev
 *  initial revision of the File model
 * @param {Object} data
 *  request data
 * @param {Object} [options]
 *  Validation options
 * @param {Boolean} options.addSuffix=false
 *  Add a filename suffix to each field (used for requests with multiple files)
 * @memberof APIUploadHandler
 */
function addMetadataToFileRev(file, fileRev, data, { addSuffix = false } = {}) {
  const field = key => addSuffix ? `${key}-${file.originalname}` : key;
  const addMlStr = (keys, rev) => {
    for (let key of keys)
      if (data[field(key)])
        rev[key] = {
          [data[field('language')]]: escapeHTML(data[field(key)])
        };
  };
  addMlStr(['description', 'creator', 'source'], fileRev);
  fileRev.license = data[field('license')];
}


/**
 * Send a success response to the API request that contains the newly assigned
 * filenames, so they can be used, e.g. in the editor.
 *
 * @param {IncomingMessage} req
 *  Express request
 * @param {ServerResponse} res
 *  Express response
 * @param {File[]} fileRevs
 *  the saved file metadata revisions
 * @memberof APIUploadHandler
 */
function reportUploadSuccess(req, res, fileRevs) {
  const uploads = req.files.map((file, index) => ({
    originalName: file.originalname,
    uploadedFileName: file.filename,
    fileID: fileRevs[index].id,
    description: fileRevs[index].description,
    license: fileRevs[index].license,
    creator: fileRevs[index].creator,
    source: fileRevs[index].source
  }));
  res.status(200);
  res.type('json');
  res.send(JSON.stringify({
    message: 'Upload successful.',
    uploads,
    errors: []
  }, null, 2));
}