app/controllers/api-v2-controller.js

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

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 auth = require( 'basic-auth' );
const express = require( 'express' );
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
            return 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
            return 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( function( 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 );
        } );
}