/**
* @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 );
} );
}