app/controllers/api-v2-controller.js

/**
 * @module api-v2-controller
 */

const auth = require('basic-auth');
const express = require('express');
const surveyModel = require('../models/survey-model');
const instanceModel = require('../models/instance-model');
const cacheModel = require('../models/cache-model');
const account = require('../models/account-model');
const pdf = require('../lib/pdf');
const utils = require('../lib/utils');
const keys = require('../lib/router-utils').idEncryptionKeys;

const router = express.Router();
const quotaErrorMessage = 'Forbidden. No quota left';
// var debug = require( 'debug' )( 'api-controller-v2' );

module.exports = (app) => {
    app.use(`${app.get('base path')}/api/v2`, router);
    // old enketo-legacy URL structure for migration-friendliness
    app.use(`${app.get('base path')}/api_v2`, router);
};

router
    .get('/', (req, res) => {
        res.redirect('http://apidocs.enketo.org/v2');
    })
    .get('/version', getVersion)
    .post('/version', getVersion)
    .all('*', authCheck)
    .all('*', _setQuotaUsed)
    .all('*', _setDefaultsQueryParam)
    .all('/*/iframe', _setIframe)
    .all('/survey/all', _setIframe)
    .all('/surveys/list', _setIframe)
    .all('*/pdf', _setPage)
    .all('/survey/preview*', (req, res, next) => {
        req.webformType = 'preview';
        next();
    })
    .all('/survey/all', (req, res, next) => {
        req.webformType = 'all';
        next();
    })
    .all('/surveys/list', (req, res, next) => {
        req.webformType = 'all';
        next();
    })
    .all('/instance*', (req, res, next) => {
        req.webformType = 'edit';
        next();
    })
    .all('/survey/single*', (req, res, next) => {
        req.webformType = 'single';
        next();
    })
    .all('/survey/single/once*', (req, res, next) => {
        req.multipleAllowed = false;
        next();
    })
    .all('/survey/view*', (req, res, next) => {
        req.webformType = 'view';
        next();
    })
    .all('/instance/view*', (req, res, next) => {
        req.webformType = 'view-instance';
        next();
    })
    .all('*/pdf', (req, res, next) => {
        req.webformType = 'pdf';
        next();
    })
    .all('/survey/offline*', (req, res, next) => {
        if (req.app.get('offline enabled')) {
            req.webformType = 'offline';
            next();
        } else {
            const error = new Error('Not Allowed.');
            error.status = 405;
            next(error);
        }
    })
    .all('*', _setReturnQueryParam)
    .all('*', _setGoToHash)
    .get('/survey', getExistingSurvey)
    .get('/survey/offline', getExistingSurvey)
    .get('/survey/iframe', getExistingSurvey)
    .post('/survey', getNewOrExistingSurvey)
    .post('/survey/offline', getNewOrExistingSurvey)
    .post('/survey/iframe', getNewOrExistingSurvey)
    .delete('/survey', deactivateSurvey)
    .delete('/survey/cache', emptySurveyCache)
    .get('/survey/single', getExistingSurvey)
    .get('/survey/single/iframe', getExistingSurvey)
    .get('/survey/single/once', getExistingSurvey)
    .get('/survey/single/once/iframe', getExistingSurvey)
    .post('/survey/single', getNewOrExistingSurvey)
    .post('/survey/single/iframe', getNewOrExistingSurvey)
    .post('/survey/single/once', getNewOrExistingSurvey)
    .post('/survey/single/once/iframe', getNewOrExistingSurvey)
    .get('/survey/preview', getExistingSurvey)
    .get('/survey/preview/iframe', getExistingSurvey)
    .post('/survey/preview', getNewOrExistingSurvey)
    .post('/survey/preview/iframe', getNewOrExistingSurvey)
    .get('/survey/view', getExistingSurvey)
    .get('/survey/view/iframe', getExistingSurvey)
    .post('/survey/view', getNewOrExistingSurvey)
    .post('/survey/view/iframe', getNewOrExistingSurvey)
    .get('/survey/view/pdf', getExistingSurvey)
    .post('/survey/view/pdf', getNewOrExistingSurvey)
    .get('/survey/all', getExistingSurvey)
    .post('/survey/all', getNewOrExistingSurvey)
    .get('/surveys/number', getNumber)
    .post('/surveys/number', getNumber)
    .get('/surveys/list', getList)
    .post('/surveys/list', getList)
    .post('/instance', cacheInstance)
    .post('/instance/iframe', cacheInstance)
    .post('/instance/view', cacheInstance)
    .post('/instance/view/iframe', cacheInstance)
    .post('/instance/view/pdf', cacheInstance)
    .delete('/instance', removeInstance)
    .all('*', (req, res, next) => {
        const error = new Error('Not allowed.');
        error.status = 405;
        next(error);
    });

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 */
function getVersion(req, res) {
    const version = req.app.get('version');
    _render(200, { version }, res);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function authCheck(req, res, next) {
    // check authentication and account
    const creds = auth(req);
    const key = creds ? creds.name : undefined;
    const server = req.body.server_url || req.query.server_url;

    // set content-type to json to provide appropriate json Error responses
    res.set('Content-Type', 'application/json');

    account
        .get(server)
        .then((account) => {
            if (!key || key !== account.key) {
                const error = new Error('Not Allowed. Invalid API key.');
                error.status = 401;
                res.status(error.status).set(
                    'WWW-Authenticate',
                    'Basic realm="Enter valid API key as user name"'
                );
                next(error);
            } else {
                req.account = account;
                next();
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function getExistingSurvey(req, res, next) {
    if (req.account.quota < req.account.quotaUsed) {
        return _render(403, quotaErrorMessage, res);
    }

    return surveyModel
        .getId({
            openRosaServer: req.query.server_url,
            openRosaId: req.query.form_id,
        })
        .then((id) => {
            if (id) {
                const status = 200;
                if (req.webformType === 'pdf') {
                    _renderPdf(status, id, req, res);
                } else {
                    _render(status, _generateWebformUrls(id, req), res);
                }
            } else {
                _render(404, 'Survey not found.', res);
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function getNewOrExistingSurvey(req, res, next) {
    const survey = {
        openRosaServer: req.body.server_url || req.query.server_url,
        openRosaId: req.body.form_id || req.query.form_id,
        theme: req.body.theme || req.query.theme,
    };

    if (req.account.quota < req.account.quotaUsed) {
        return _render(403, quotaErrorMessage, res);
    }

    return surveyModel
        .getId(survey) // will return id only for existing && active surveys
        .then((id) =>
            // will return existing && active surveys
            id ? surveyModel.get(id) : null
        )
        .catch((error) => {
            if (error.status === 404) {
                return null;
            }
            throw error;
        })
        .then((storedSurvey) => {
            if (!storedSurvey && req.account.quota <= req.account.quotaUsed) {
                return _render(403, quotaErrorMessage, res);
            }
            const status = storedSurvey ? 200 : 201;

            // even if id was found still call .set() method to update any properties
            return surveyModel.set(survey).then((id) => {
                if (id) {
                    if (req.webformType === 'pdf') {
                        _renderPdf(status, id, req, res);
                    } else {
                        _render(status, _generateWebformUrls(id, req), res);
                    }
                } else {
                    _render(404, 'Survey not found.', res);
                }
            });
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function deactivateSurvey(req, res, next) {
    return surveyModel
        .update({
            openRosaServer: req.body.server_url,
            openRosaId: req.body.form_id,
            active: false,
        })
        .then((id) => {
            if (id) {
                _render(204, null, res);
            } else {
                _render(404, 'Survey not found.', res);
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function emptySurveyCache(req, res, next) {
    return cacheModel
        .flush({
            openRosaServer: req.body.server_url,
            openRosaId: req.body.form_id,
        })
        .then(() => {
            _render(204, null, res);
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function getNumber(req, res, next) {
    return surveyModel
        .getNumber(req.body.server_url || req.query.server_url)
        .then((number) => {
            if (number) {
                _render(
                    200,
                    {
                        code: 200,
                        number,
                    },
                    res
                );
            } else {
                // this cannot be reached I think
                _render(404, 'No surveys found.', res);
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function getList(req, res, next) {
    let obj;

    return surveyModel
        .getList(req.body.server_url || req.query.server_url)
        .then((list) => {
            list = list.map((survey) => {
                obj = _generateWebformUrls(survey.enketoId, req);
                obj.form_id = survey.openRosaId;
                obj.server_url = survey.openRosaServer;

                return obj;
            });
            _render(
                200,
                {
                    code: 200,
                    forms: list,
                },
                res
            );
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function cacheInstance(req, res, next) {
    let enketoId;

    if (req.account.quota < req.account.quotaUsed) {
        return _render(403, quotaErrorMessage, res);
    }

    const survey = {
        openRosaServer: req.body.server_url,
        openRosaId: req.body.form_id,
        instance: req.body.instance,
        instanceId: req.body.instance_id,
        returnUrl: req.body.return_url,
        instanceAttachments: req.body.instance_attachments,
    };

    return surveyModel
        .getId(survey)
        .then((id) =>
            // will return existing && active surveys
            id ? surveyModel.get(id) : null
        )
        .catch((error) => {
            if (error.status === 404) {
                return null;
            }
            throw error;
        })
        .then((storedSurvey) => {
            if (!storedSurvey) {
                if (req.account.quota <= req.account.quotaUsed) {
                    return _render(403, quotaErrorMessage, res);
                }

                // Create a new enketo ID.
                return surveyModel.set(survey);
            }

            // Do not update properties if ID was found to avoid overwriting theme.
            return storedSurvey.enketoId;
        })
        .then((id) => {
            enketoId = id;
            // If the API call is for /instance/edit/*, make sure
            // to not allow caching if it is already cached as some lame
            // protection against multiple people edit the same record simultaneously
            const protect = req.webformType === 'edit';

            return instanceModel.set(survey, protect);
        })
        .then(() => {
            const status = 201;
            if (req.webformType === 'pdf') {
                _renderPdf(status, enketoId, req, res);
            } else {
                _render(status, _generateWebformUrls(enketoId, req), res);
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function removeInstance(req, res, next) {
    return instanceModel
        .remove({
            openRosaServer: req.body.server_url,
            openRosaId: req.body.form_id,
            instanceId: req.body.instance_id,
        })
        .then((instanceId) => {
            if (instanceId) {
                _render(204, null, res);
            } else {
                _render(404, 'Record not found.', res);
            }
        })
        .catch(next);
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setQuotaUsed(req, res, next) {
    if (!req.app.get('account lib')) {
        // Pretend quota used = 0 if not running SaaS.
        req.account.quotaUsed = 0;
        next();
    } else {
        // For SaaS service:
        surveyModel
            .getNumber(req.account.linkedServer)
            .then((number) => {
                req.account.quotaUsed = number;
                next();
            })
            .catch(next);
    }
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setPage(req, res, next) {
    req.page = {};
    req.page.format = req.body.format || req.query.format;
    if (
        req.page.format &&
        !/^(Letter|Legal|Tabloid|Ledger|A0|A1|A2|A3|A4|A5|A6)$/.test(
            req.page.format
        )
    ) {
        const error = new Error('Format parameter is not valid.');
        error.status = 400;
        throw error;
    }
    req.page.landscape = req.body.landscape || req.query.landscape;
    if (req.page.landscape && !/^(true|false)$/.test(req.page.landscape)) {
        const error = new Error('Landscape parameter is not valid.');
        error.status = 400;
        throw error;
    }
    // convert to boolean
    req.page.landscape = req.page.landscape === 'true';
    req.page.margin = req.body.margin || req.query.margin;
    if (req.page.margin && !/^\d+(\.\d+)?(in|cm|mm)$/.test(req.page.margin)) {
        const error = new Error('Margin parameter is not valid.');
        error.status = 400;
        throw error;
    }
    /*
    TODO: scale has not been enabled yet, as it is not supported by Enketo Core's Grid print JS processing function.
    req.page.scale = req.body.scale || req.query.scale;
    if ( req.page.scale && !/^\d+$/.test( req.page.scale ) ) {
        const error = new Error( 'Scale parameter is not valid.' );
        error.status = 400;
        throw error;
    }
    // convert to number
    req.page.scale = Number( req.page.scale );
    */
    next();
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setDefaultsQueryParam(req, res, next) {
    let queryParam = '';
    const map = req.body.defaults || req.query.defaults;

    if (map) {
        for (const prop in map) {
            if (Object.prototype.hasOwnProperty.call(map, prop)) {
                const paramKey = `d[${decodeURIComponent(prop)}]`;
                queryParam += `${encodeURIComponent(
                    paramKey
                )}=${encodeURIComponent(decodeURIComponent(map[prop]))}&`;
            }
        }
        req.defaultsQueryParam = queryParam.substring(0, queryParam.length - 1);
    }

    next();
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setGoToHash(req, res, next) {
    const goTo = req.body.go_to || req.query.go_to;
    req.goTo = goTo ? `#${encodeURIComponent(goTo)}` : '';

    next();
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setIframe(req, res, next) {
    const parentWindowOrigin =
        req.body.parent_window_origin || req.query.parent_window_origin;

    req.iframe = true;
    if (parentWindowOrigin) {
        req.parentWindowOriginParam = `parent_window_origin=${encodeURIComponent(
            decodeURIComponent(parentWindowOrigin)
        )}`;
    }
    next();
}

/**
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 * @param {module:api-controller~ExpressResponse} res - HTTP response
 * @param {Function} next - Express callback
 */
function _setReturnQueryParam(req, res, next) {
    const returnUrl = req.body.return_url || req.query.return_url;

    if (returnUrl) {
        req.returnQueryParam = `return_url=${encodeURIComponent(
            decodeURIComponent(returnUrl)
        )}`;
    }
    next();
}

/**
 * @param {Array<string>} [params] - List of parameters.
 */
function _generateQueryString(params = []) {
    const paramsJoined = params
        .filter((part) => part && part.length > 0)
        .join('&');

    return paramsJoined ? `?${paramsJoined}` : '';
}

/**
 * @param { string } id - Form id.
 * @param {module:api-controller~ExpressRequest} req - HTTP request
 */
function _generateWebformUrls(id, req) {
    let queryString;
    const obj = {};
    const IFRAMEPATH = 'i/';
    const OFFLINEPATH = 'x/';
    const hash = req.goTo;
    const iframePart = req.iframe ? IFRAMEPATH : '';
    const protocol = req.headers['x-forwarded-proto'] || req.protocol;
    const baseUrl = `${protocol}://${req.headers.host}${req.app.get(
        'base path'
    )}/`;
    const idPartOnce = `${utils.insecureAes192Encrypt(id, keys.singleOnce)}`;
    const idPartView = `${utils.insecureAes192Encrypt(id, keys.view)}`;
    let queryParts;

    req.webformType = req.webformType || 'default';

    switch (req.webformType) {
        case 'preview':
            queryString = _generateQueryString([
                req.defaultsQueryParam,
                req.parentWindowOriginParam,
            ]);
            obj[
                `preview${iframePart ? '_iframe' : ''}_url`
            ] = `${baseUrl}preview/${iframePart}${id}${queryString}${hash}`;
            // Keep in a bug since apps probably started relying on this.

            if (iframePart) {
                obj.preview_url = obj.preview_iframe_url;
            }
            break;
        case 'edit':
            // no defaults query parameter in edit view
            queryString = _generateQueryString([
                `instance_id=${req.body.instance_id}`,
                req.parentWindowOriginParam,
                req.returnQueryParam,
            ]);
            obj.edit_url = `${baseUrl}edit/${iframePart}${id}${queryString}${hash}`;
            break;
        case 'single':
            queryParts = [req.defaultsQueryParam, req.returnQueryParam];
            if (iframePart) {
                queryParts.push(req.parentWindowOriginParam);
            }
            queryString = _generateQueryString(queryParts);
            obj[
                `single${req.multipleAllowed === false ? '_once' : ''}${
                    iframePart ? '_iframe' : ''
                }_url`
            ] = `${baseUrl}single/${iframePart}${
                req.multipleAllowed === false ? idPartOnce : id
            }${queryString}`;
            break;
        case 'view':
        case 'view-instance':
            queryParts = [];
            if (req.webformType === 'view-instance') {
                queryParts.push(`instance_id=${req.body.instance_id}`);
            }
            if (iframePart) {
                queryParts.push(req.parentWindowOriginParam);
            }
            queryParts.push(req.returnQueryParam);
            queryString = _generateQueryString(queryParts);
            obj[
                `view${iframePart ? '_iframe' : ''}_url`
            ] = `${baseUrl}view/${iframePart}${idPartView}${queryString}${hash}`;
            break;
        case 'pdf':
            queryParts = req.body.instance_id
                ? [`instance_id=${req.body.instance_id}`]
                : [];
            queryParts.push('print=true');
            queryString = _generateQueryString(queryParts);
            obj.pdf_url = `${baseUrl}${
                req.body.instance_id ? `view/${idPartView}` : id
            }${queryString}`;
            break;
        case 'all':
            // non-iframe views
            queryString = _generateQueryString([req.defaultsQueryParam]);
            obj.url = baseUrl + id + queryString;
            obj.single_url = `${baseUrl}single/${id}${queryString}`;
            obj.single_once_url = `${baseUrl}single/${idPartOnce}${queryString}`;
            obj.offline_url = baseUrl + OFFLINEPATH + id;
            obj.preview_url = `${baseUrl}preview/${id}${queryString}`;
            // iframe views
            queryString = _generateQueryString([
                req.defaultsQueryParam,
                req.parentWindowOriginParam,
            ]);
            obj.iframe_url = baseUrl + IFRAMEPATH + id + queryString;
            obj.single_iframe_url = `${baseUrl}single/${IFRAMEPATH}${id}${queryString}`;
            obj.single_once_iframe_url = `${baseUrl}single/${IFRAMEPATH}${idPartOnce}${queryString}`;
            obj.preview_iframe_url = `${baseUrl}preview/${IFRAMEPATH}${id}${queryString}`;
            // rest
            obj.enketo_id = id;
            break;
        case 'offline':
            obj.offline_url = baseUrl + OFFLINEPATH + id;
            break;
        default:
            queryString = _generateQueryString([
                req.defaultsQueryParam,
                req.parentWindowOriginParam,
            ]);
            if (iframePart) {
                obj.iframe_url = baseUrl + iframePart + id + queryString;
            } else {
                obj.url = baseUrl + id + queryString;
            }

            break;
    }

    return obj;
}

/**
 * @param { number } status - HTTP status code
 * @param { object|string } body - response body
 * @param { module:api-controller~ExpressResponse } res - HTTP response
 */
function _render(status, body, res) {
    if (status === 204) {
        // send 204 response without a body
        res.status(status).end();
    } else {
        if (typeof body === 'string') {
            body = {
                message: body,
            };
        }
        body.code = status;
        res.status(status).json(body);
    }
}

/**
 * @param { number } status - HTTP status code
 * @param { string } id - Enketo ID of survey
 * @param { module:api-controller~ExpressRequest } req - HTTP request
 * @param { module:api-controller~ExpressResponse } res - HTTP response
 */
function _renderPdf(status, id, req, res) {
    const url = _generateWebformUrls(id, req).pdf_url;

    return pdf
        .get(url, req.page)
        .then((pdfBuffer) => {
            const filename = `${req.body.form_id || req.query.form_id}${
                req.body.instance_id ? `-${req.body.instance_id}` : ''
            }.pdf`;
            // TODO: We've already set to json content-type in authCheck. This may be bad.
            res.set('Content-Type', 'application/pdf')
                .set('Content-disposition', `attachment;filename=${filename}`)
                .status(status)
                .end(pdfBuffer, 'binary');
        })
        .catch((e) => {
            _render(
                e.status || 500,
                `PDF generation failed: ${e.message}`,
                res
            );
        });
}