app/lib/communicator.js

/**
 * @module communicator
 */

const request = require('request');
const { Auth } = require('request/lib/auth');
const TError = require('./custom-error').TranslatedError;
const config = require('../models/config-model').server;
const debug = require('debug')('enketo:openrosa-communicator');
const Xml2Js = require('xml2js');

const parser = new Xml2Js.Parser();
const { getCurrentRequest } = require('./context');

const TIMEOUT = config.timeout;

/**
 * Gets form info
 *
 *
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added info
 */
function getXFormInfo(survey) {
    if (!survey || !survey.openRosaServer) {
        throw new Error('No server provided.');
    }

    return _request({
        url: getFormListUrl(
            survey.openRosaServer,
            survey.openRosaId,
            survey.customParam
        ),
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie,
        },
    }).then((formListXml) => _findFormAddInfo(formListXml, survey));
}

/**
 * @typedef OpenRosaXForm
 * @property {string} descriptionText
 * @property {string} downloadUrl
 * @property {string} formID
 * @property {string} hash
 * @property {string} manifestUrl
 * @property {string} name
 * @property {string} version
 */

/**
 * Gets XForm from url
 *
 * @static
 * @param  { object } survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added XForm
 */
function getXForm(survey) {
    return _request({
        url: survey.info.downloadUrl,
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie,
        },
    }).then((xform) => {
        survey.xform = xform;

        return Promise.resolve(survey);
    });
}

/**
 * Obtains the XForm manifest
 *
 * @static
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added manifest
 */
function getManifest(survey) {
    if (survey.info == null || !survey.info.manifestUrl) {
        return Promise.resolve({
            ...survey,
            manifest: [],
        });
    }
    return _request({
        url: survey.info.manifestUrl,
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie,
        },
    })
        .then(_xmlToJson)
        .then((obj) => {
            survey.manifest =
                obj.manifest && obj.manifest.mediaFile
                    ? obj.manifest.mediaFile.map((file) =>
                          _simplifyFormObj(file)
                      )
                    : [];

            return survey;
        });
}

/**
 * Checks the maximum acceptable submission size the server accepts
 *
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<string> } promise resolving with max size stringified number
 */
function getMaxSize(survey) {
    // Using survey.xformUrl is non-standard but the only way for previews served from `?form=URL`.
    const submissionUrl = survey.openRosaServer
        ? getSubmissionUrl(survey.openRosaServer)
        : survey.info.downloadUrl;

    const options = {
        url: submissionUrl,
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie,
        },
        method: 'head',
    };

    return _request(options).then(
        (response) => response.headers['x-openrosa-accept-content-length']
    );
}

/**
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a promise that resolves with a survey object
 */
function authenticate(survey) {
    const options = {
        url: getFormListUrl(
            survey.openRosaServer,
            survey.openRosaId,
            survey.customParam
        ),
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie,
        },
        // Formhub has a bug and cannot use the correct HEAD method.
        method: config['linked form and data server']['legacy formhub']
            ? 'get'
            : 'head',
    };

    return _request(options).then(() => {
        debug('successful (authenticated if it was necessary)');

        return survey;
    });
}

/**
 * Generates an Auhorization header that can be used to inject into piped requests (e.g. submissions).
 *
 * @static
 * @param { string } url - URL to request
 * @param { {user: string, pass: string, bearer: string} } [credentials] - user credentials
 * @return { Promise } a promise that resolves with an auth header
 */
function getAuthHeader(url, credentials) {
    const options = {
        url,
        method: 'head',
        headers: {
            'X-OpenRosa-Version': '1.0',
            Date: new Date().toUTCString(),
        },
        timeout: TIMEOUT,
    };

    return new Promise((resolve) => {
        // Don't bother making Head request first if token was provided.
        if (credentials && credentials.bearer) {
            resolve(`Bearer ${credentials.bearer}`);
        } else {
            // Check if Basic or Digest Authorization header is required and return header if so.
            const req = request(options, (error, response) => {
                if (
                    !error &&
                    response &&
                    response.statusCode === 401 &&
                    credentials &&
                    credentials.user &&
                    credentials.pass
                ) {
                    // Using request's internal library we create an appropiate authorization header.
                    // This is a bit dangerous because internal changes in request/request, could break this code.
                    req.method = 'POST';
                    const auth = new Auth(req);
                    auth.hasAuth = true;
                    auth.user = credentials.user;
                    auth.pass = credentials.pass;
                    const authHeader = auth.onResponse(response);
                    resolve(authHeader);
                } else {
                    resolve(null);
                }
            });
        }
    });
}

/**
 * getFormListUrl
 *
 * @static
 * @param { string } server - server URL
 * @param { string } [id] - Form id.
 * @param { string } [customParam] - custom query parameter
 * @return { string } url
 */
function getFormListUrl(server, id, customParam) {
    const baseURL = server.endsWith('/') ? server : `${server}/`;

    const url = new URL('./formList', baseURL);

    if (id != null) {
        url.searchParams.set('formID', id);
    }

    if (customParam != null) {
        const customParamName = config['query parameter to pass to submission'];

        url.searchParams.set(customParamName, customParam);
    }

    return url.toString();
}

/**
 * @static
 * @param { string } server - server URL
 * @return { string } url
 */
function getSubmissionUrl(server) {
    return server.lastIndexOf('/') === server.length - 1
        ? `${server}submission`
        : `${server}/submission`;
}

/**
 * @param {string} value
 */
const sanitizeHeader = (value) =>
    value
        .trim()
        .replace(/\s+/g, ' ')
        // See https://github.com/nodejs/node/blob/3d53ff8ff0e721f908d8aff7a3709bc6dbb07ebb/lib/_http_common.js#L232
        .replace(/[^\t\x20-\x7e\x80-\xff]+/g, (match) => encodeURI(match));

/**
 * @param {Record<string, string | string[]>} [headers]
 * @param {import('express').Request} [currentRequest]
 */
const getUpdatedRequestHeaders = (
    headers = {},
    currentRequest = getCurrentRequest()
) => {
    const clientUserAgent = currentRequest?.headers['user-agent'];
    const serverUserAgent = `Enketo/${config.version}`;
    const userAgent =
        clientUserAgent == null
            ? serverUserAgent
            : `${serverUserAgent} ${clientUserAgent}`;

    return {
        ...headers,

        // The Date header is forbidden to set programmatically client-side
        // so we set it here to comply with OpenRosa
        Date: new Date().toUTCString(),
        'User-Agent': sanitizeHeader(userAgent),
        'X-OpenRosa-Version': '1.0',
    };
};

/**
 * Updates request options.
 *
 * @static
 * @param { object } options - request options
 */
function getUpdatedRequestOptions(options) {
    options.method = options.method || 'get';

    // set headers
    options.headers = getUpdatedRequestHeaders(options.headers);
    options.timeout = TIMEOUT;

    if (!options.headers.cookie) {
        // remove undefined cookie
        delete options.headers.cookie;
    }

    // set Authorization header
    if (!options.auth) {
        delete options.auth;
    } else if (!options.auth.bearer) {
        // check first is DIGEST or BASIC is required
        options.auth.sendImmediately = false;
    }

    return options;
}

/**
 * Sends a request to an OpenRosa server
 *
 * @param {{url: string}} options - request options object
 * @return { Promise } Promise
 */
function _request(options) {
    let error;

    return new Promise((resolve, reject) => {
        if (typeof options !== 'object' && !options.url) {
            error = new Error('Bad request. No options provided.');
            error.status = 400;
            reject(error);
        }

        options = getUpdatedRequestOptions(options);

        // due to a bug in request/request using options.method with Digest Auth we won't pass method as an option
        const { method } = options;
        delete options.method;

        debug(`sending ${method} request to url: ${options.url}`);

        request[method](options, (error, response, body) => {
            if (error) {
                debug(`Error occurred when requesting ${options.url}`, error);
                reject(error);
            } else if (response.statusCode === 401) {
                error = new Error('Forbidden. Authorization Required.');
                error.status = response.statusCode;
                reject(error);
            } else if (
                response.statusCode < 200 ||
                response.statusCode >= 300
            ) {
                error = new Error(`Request to ${options.url} failed.`);
                error.status = response.statusCode;
                reject(error);
            } else if (method === 'head') {
                resolve(response);
            } else {
                debug(
                    `response of request to ${options.url} has status code: `,
                    response.statusCode
                );
                resolve(body);
            }
        });
    });
}

/**
 * transform XML to JSON for easier processing
 *
 * @param { string } xml - XML string
 * @return {Promise<string|Error>} a promise that resolves with JSON
 */
function _xmlToJson(xml) {
    return new Promise((resolve, reject) => {
        parser.parseString(xml, (error, data) => {
            if (error) {
                debug('error parsing xml and converting to JSON');
                reject(error);
            } else {
                resolve(data);
            }
        });
    });
}

/**
 * Finds the relevant form in an OpenRosa XML formList
 *
 * @param { string } formListXml - OpenRosa XML formList
 * @param {module:survey-model~SurveyObject} survey - survey object
 * * @return { Promise } promise
 */
function _findFormAddInfo(formListXml, survey) {
    let found;
    let index;
    let error;

    return new Promise((resolve, reject) => {
        // first convert to JSON to make it easier to work with
        _xmlToJson(formListXml)
            .then((formListObj) => {
                if (formListObj.xforms && formListObj.xforms.xform) {
                    // find the form and stop looking when found
                    found = formListObj.xforms.xform.some((xform, i) => {
                        index = i;

                        return xform.formID.toString() === survey.openRosaId;
                    });
                }

                if (!found) {
                    error = new TError('error.notfoundinformlist', {
                        formId: survey.openRosaId,
                    });
                    error.status = 404;
                    reject(error);
                } else {
                    debug('found form');
                    survey.info = _simplifyFormObj(
                        formListObj.xforms.xform[index]
                    );
                    debug('survey.info', survey.info);
                    resolve(survey);
                }
            })
            .catch(reject);
    });
}

/**
 * Convert arrays property values to strings, knowing that each xml node only
 * occurs once in each xform node in /formList
 *
 * @param { object } formObj - a form object
 * @return { object } a simplified form object
 */
function _simplifyFormObj(formObj) {
    for (const prop in formObj) {
        if (
            Object.prototype.hasOwnProperty.call(formObj, prop) &&
            Object.prototype.toString.call(formObj[prop]) === '[object Array]'
        ) {
            formObj[prop] = formObj[prop][0].toString();
        }
    }

    return formObj;
}

module.exports = {
    getXFormInfo,
    getXForm,
    getManifest,
    getMaxSize,
    authenticate,
    getAuthHeader,
    getFormListUrl,
    getSubmissionUrl,
    getUpdatedRequestOptions,
    getUpdatedRequestHeaders,
};