app/controllers/api-v1-controller.js

/**
 * @module api-v1-controller
 */

const surveyModel = require( '../models/survey-model' );
const instanceModel = require( '../models/instance-model' );
const account = require( '../models/account-model' );
const auth = require( 'basic-auth' );
const express = require( 'express' );
const router = express.Router();
const quotaErrorMessage = 'Forbidden. No quota left';
//var debug = require( 'debug' )( 'api-controller-v1' );

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

router
    .get( '/', ( req, res ) => {
        res.redirect( 'http://apidocs.enketo.org/v1' );
    } )
    .all( '*', authCheck )
    .all( '*', _setQuotaUsed )
    .all( '/*/iframe', _setIframe )
    .all( '/survey/preview*', ( req, res, next ) => {
        req.webformType = 'preview';
        next();
    } )
    .all( '/survey/all*', ( req, res, next ) => {
        req.webformType = 'all';
        next();
    } )
    .all( '/instance*', ( req, res, next ) => {
        req.webformType = 'edit';
        next();
    } )
    .all( '*', _setReturnQueryParam )
    .get( '/survey', getExistingSurvey )
    .get( '/survey/iframe', getExistingSurvey )
    .post( '/survey', getNewOrExistingSurvey )
    .post( '/survey/iframe', getNewOrExistingSurvey )
    .delete( '/survey', deactivateSurvey )
    .get( '/survey/preview', getExistingSurvey )
    .get( '/survey/preview/iframe', getExistingSurvey )
    .post( '/survey/preview', getNewOrExistingSurvey )
    .post( '/survey/preview/iframe', 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 )
    .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
 * @param {Function} next - Express callback
 */
function authCheck( req, res, next ) {
    // check authentication and account
    let error;
    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 ) ) {
                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 ) {
                _render( 200, _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
    };

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

    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 && 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 ) {
                        _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 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 survey;
    let enketoId;

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

    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
    };

    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;

            return instanceModel.set( survey );
        } )
        .then( () => {
            _render( 201, _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 _setIframe( req, res, next ) {
    req.iframe = true;
    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.webformType === 'edit' || req.webformType === 'single' ) ) {
        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 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 offline = req.app.get( 'offline enabled' );

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

    switch ( req.webformType ) {
        case 'preview':
            obj.preview_url = `${baseUrl}preview/${iframePart}${id}`;
            break;
        case 'edit':
            queryString = _generateQueryString( [ `instance_id=${req.body.instance_id}`, req.returnQueryParam ] );
            obj.edit_url = `${baseUrl}edit/${iframePart}${id}${queryString}`;
            break;
        case 'all':
            // non-iframe views
            obj.url = ( offline ) ? `${baseUrl}x/${id}` : baseUrl + id;
            obj.preview_url = `${baseUrl}preview/${id}`;
            // iframe views
            obj.iframe_url = baseUrl + IFRAMEPATH + id;
            obj.preview_iframe_url = `${baseUrl}preview/${IFRAMEPATH}${id}`;
            // enketo-legacy
            obj.subdomain = '';
            break;
        default:
            if ( iframePart ) {
                obj.url = ( offline ) ? `${baseUrl}x/${id}` : baseUrl + iframePart + id;
            } else {
                obj.url = ( offline ) ? `${baseUrl}x/${id}` : baseUrl + id;
            }
            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 );
    }
}