app/controllers/submission-controller.js

  1. /**
  2. * @module submissions-controller
  3. */
  4. const request = require('request');
  5. const express = require('express');
  6. const errors = require('../lib/custom-error');
  7. const mediaLib = require('../lib/media');
  8. const communicator = require('../lib/communicator');
  9. const surveyModel = require('../models/survey-model');
  10. const userModel = require('../models/user-model');
  11. const instanceModel = require('../models/instance-model');
  12. const submissionModel = require('../models/submission-model');
  13. const utils = require('../lib/utils');
  14. const router = express.Router();
  15. const routerUtils = require('../lib/router-utils');
  16. // var debug = require( 'debug' )( 'submission-controller' );
  17. module.exports = (app) => {
  18. app.use(`${app.get('base path')}/submission`, router);
  19. };
  20. router.param('enketo_id', routerUtils.enketoId);
  21. router.param('encrypted_enketo_id_single', routerUtils.encryptedEnketoIdSingle);
  22. router.param('encrypted_enketo_id_view', routerUtils.encryptedEnketoIdView);
  23. router
  24. .all('*', (req, res, next) => {
  25. res.set('Content-Type', 'application/json');
  26. next();
  27. })
  28. .get('/max-size/:encrypted_enketo_id_single', maxSize)
  29. .get('/max-size/:encrypted_enketo_id_view', maxSize)
  30. .get('/max-size/:enketo_id?', maxSize)
  31. .get('/:encrypted_enketo_id_view', getInstance)
  32. .get('/:enketo_id', getInstance)
  33. .post('/:encrypted_enketo_id_single', submit)
  34. .post('/:enketo_id', submit)
  35. .all('/*', (req, res, next) => {
  36. const error = new Error('Not allowed');
  37. error.status = 405;
  38. next(error);
  39. });
  40. /**
  41. * Simply pipes well-formed request to the OpenRosa server and
  42. * copies the response received.
  43. *
  44. * @param {express.Request} req - HTTP request
  45. * @param {express.Response} res - HTTP response
  46. * @param {Function} next - Express callback
  47. */
  48. async function submit(req, res, next) {
  49. if (!req.headers['content-type']?.startsWith('multipart/form-data')) {
  50. res.status(400)
  51. .set('content-type', 'text/xml')
  52. .send(
  53. /* xml */ `
  54. <OpenRosaResponse xmlns="http://openrosa.org/http/response" items="0">
  55. <message nature="error">Required multipart POST field xml_submission_file missing.</message>
  56. </OpenRosaResponse>
  57. `.trim()
  58. );
  59. return;
  60. }
  61. try {
  62. const paramName = req.app.get('query parameter to pass to submission');
  63. const paramValue = req.query[paramName];
  64. const query = paramValue ? `?${paramName}=${paramValue}` : '';
  65. const instanceId = req.headers['x-openrosa-instance-id'];
  66. const deprecatedId = req.headers['x-openrosa-deprecated-id'];
  67. const id = req.enketoId;
  68. const survey = await surveyModel.get(id);
  69. const submissionUrl =
  70. communicator.getSubmissionUrl(survey.openRosaServer) + query;
  71. const credentials = userModel.getCredentials(req);
  72. const authHeader = await communicator.getAuthHeader(
  73. submissionUrl,
  74. credentials
  75. );
  76. const baseHeaders = authHeader ? { Authorization: authHeader } : {};
  77. // Note even though headers is part of these options, it does not overwrite the headers set on the client!
  78. const options = {
  79. method: 'POST',
  80. url: submissionUrl,
  81. headers: communicator.getUpdatedRequestHeaders(baseHeaders, req),
  82. timeout: req.app.get('timeout') + 500,
  83. };
  84. /**
  85. * TODO: When we've replaced request with a non-deprecated library,
  86. * and as we continue to move toward async/await, we should also:
  87. *
  88. * - Eliminate this `pipe` awkwardness with e.g. `await fetch`
  89. * - Introduce a more idiomatic request async handler interface, e.g. wrapping
  90. * handlers to automatically try + res.send or catch + next(error)
  91. */
  92. req.pipe(request(options))
  93. .on('response', (orResponse) => {
  94. if (orResponse.statusCode === 201) {
  95. _logSubmission(id, instanceId, deprecatedId);
  96. } else if (orResponse.statusCode === 401) {
  97. // replace the www-authenticate header to avoid browser built-in authentication dialog
  98. orResponse.headers[
  99. 'WWW-Authenticate'
  100. ] = `enketo${orResponse.headers['WWW-Authenticate']}`;
  101. }
  102. })
  103. .on('error', (error) => {
  104. if (
  105. error &&
  106. (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')
  107. ) {
  108. if (error.connect === true) {
  109. error.status = 504;
  110. } else {
  111. error.status = 408;
  112. }
  113. }
  114. next(error);
  115. })
  116. .pipe(res);
  117. } catch (error) {
  118. next(error);
  119. }
  120. }
  121. /**
  122. * Get max submission size.
  123. *
  124. * @param {module:api-controller~ExpressRequest} req - HTTP request
  125. * @param {module:api-controller~ExpressResponse} res - HTTP response
  126. * @param {Function} next - Express callback
  127. */
  128. function maxSize(req, res, next) {
  129. if (req.query.xformUrl) {
  130. // Non-standard way of attempting to obtain max submission size from XForm url directly
  131. communicator
  132. .getMaxSize({
  133. info: {
  134. downloadUrl: req.query.xformUrl,
  135. },
  136. })
  137. .then((maxSize) => {
  138. res.json({ maxSize });
  139. })
  140. .catch(next);
  141. } else {
  142. surveyModel
  143. .get(req.enketoId)
  144. .then((survey) => {
  145. survey.credentials = userModel.getCredentials(req);
  146. return survey;
  147. })
  148. .then(communicator.getMaxSize)
  149. .then((maxSize) => {
  150. res.json({ maxSize });
  151. })
  152. .catch(next);
  153. }
  154. }
  155. /**
  156. * Obtains cached instance (for editing)
  157. *
  158. * @param {module:api-controller~ExpressRequest} req - HTTP request
  159. * @param {module:api-controller~ExpressResponse} res - HTTP response
  160. * @param {Function} next - Express callback
  161. */
  162. async function getInstance(req, res, next) {
  163. try {
  164. const survey = await surveyModel.get(req.enketoId);
  165. const instance = await instanceModel.get({
  166. instanceId: req.query.instanceId,
  167. });
  168. if (utils.getOpenRosaKey(survey) !== instance.openRosaKey) {
  169. throw new errors.ResponseError(
  170. 400,
  171. "Instance doesn't belong to this form"
  172. );
  173. }
  174. const instanceAttachments = await mediaLib.getMediaMap(
  175. instance.instanceId,
  176. instance.instanceAttachments,
  177. mediaLib.getHostURLOptions(req)
  178. );
  179. res.json({
  180. instance: instance.instance,
  181. instanceAttachments,
  182. });
  183. } catch (error) {
  184. next(error);
  185. }
  186. }
  187. /**
  188. * @param { string } id - Enketo ID of survey
  189. * @param { string } instanceId - instance ID of record
  190. * @param { string } deprecatedId - deprecated (previous) ID of record
  191. */
  192. function _logSubmission(id, instanceId, deprecatedId) {
  193. submissionModel
  194. .isNew(id, instanceId)
  195. .then((notRecorded) => {
  196. if (notRecorded) {
  197. // increment number of submissions
  198. surveyModel.incrementSubmissions(id);
  199. // store/log instanceId
  200. submissionModel.add(id, instanceId, deprecatedId);
  201. }
  202. })
  203. .catch((error) => {
  204. console.error(error);
  205. });
  206. }