From 9ee97bb99f9b10fe9161e6249179a4fd4291c6a1 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Mon, 2 Oct 2023 22:08:21 +0800 Subject: [PATCH] feat: storage submission with virus scans --- .../public-form/PublicFormProvider.tsx | 21 ++++- .../features/public-form/PublicFormService.ts | 66 +++++++++++++-- .../src/features/public-form/mutations.ts | 83 ++++++++++++------- .../public-form/utils/createSubmission.ts | 52 ++++++++++++ 4 files changed, 185 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index 6d37368168..824c125129 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -17,12 +17,10 @@ import get from 'lodash/get' import simplur from 'simplur' import { - ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, featureFlags, PAYMENT_CONTACT_FIELD_ID, PAYMENT_PRODUCT_FIELD_ID, PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID, - VIRUS_SCANNER_SUBMISSION_VERSION, } from '~shared/constants' import { BasicField, PaymentType } from '~shared/types' import { CaptchaTypes } from '~shared/types/captcha' @@ -560,8 +558,23 @@ export const PublicFormProvider = ({ return ( submitStorageModeClearFormWithVirusScanningMutation .mutateAsync(formData, { - onSuccess: async (presignedUrls) => { - console.log('presignedUrls', presignedUrls) + onSuccess: ({ + submissionId, + timestamp, + // payment forms will have non-empty paymentData field + paymentData, + }) => { + trackSubmitForm(form) + + if (paymentData) { + navigate(getPaymentPageUrl(formId, paymentData.paymentId)) + storePaymentMemory(paymentData.paymentId) + return + } + setSubmissionData({ + id: submissionId, + timestamp, + }) }, }) // Using catch since we are using mutateAsync and react-hook-form will continue bubbling this up. diff --git a/frontend/src/features/public-form/PublicFormService.ts b/frontend/src/features/public-form/PublicFormService.ts index 6df711bfc5..17d5073190 100644 --- a/frontend/src/features/public-form/PublicFormService.ts +++ b/frontend/src/features/public-form/PublicFormService.ts @@ -1,7 +1,10 @@ import { PresignedPost } from 'aws-sdk/clients/s3' import axios from 'axios' -import { ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION } from '~shared/constants' +import { + ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, + VIRUS_SCANNER_SUBMISSION_VERSION, +} from '~shared/constants' import { SubmitFormIssueBodyDto, SuccessMessageDto } from '~shared/types' import { AttachmentPresignedPostDataMapType, @@ -35,6 +38,7 @@ import { FormFieldValues } from '~templates/Field' import { createClearSubmissionFormData, + createClearSubmissionWithVirusScanningFormData, createEncryptedSubmissionData, getAttachmentsMap, } from './utils/createSubmission' @@ -110,6 +114,16 @@ export type SubmitStorageFormClearArgs = SubmitEmailFormArgs & { version?: number } +export type FieldIdToQuarantineKeyType = { + fieldId: string + quarantineBucketKey: string +} + +export type SubmitStorageFormWithVirusScanningArgs = + SubmitStorageFormClearArgs & { + fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[] + } + export const submitEmailModeForm = async ({ formFields, formLogics, @@ -192,7 +206,6 @@ export const submitStorageModeClearForm = async ({ responseMetadata, paymentProducts, payments, - version = ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, }: SubmitStorageFormClearArgs) => { const filteredInputs = filterHiddenInputs({ formFields, @@ -207,7 +220,7 @@ export const submitStorageModeClearForm = async ({ paymentReceiptEmail, paymentProducts, payments, - version, + version: ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, }) return ApiService.post( @@ -234,7 +247,6 @@ export const submitStorageModeClearFormWithFetch = async ({ responseMetadata, paymentProducts, payments, - version = ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, }: SubmitStorageFormClearArgs) => { const filteredInputs = filterHiddenInputs({ formFields, @@ -249,7 +261,7 @@ export const submitStorageModeClearFormWithFetch = async ({ paymentReceiptEmail, paymentProducts, payments, - version, + version: ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, }) // Add captcha response to query string @@ -272,6 +284,50 @@ export const submitStorageModeClearFormWithFetch = async ({ return processFetchResponse(response) } +export const submitStorageModeClearFormWithVirusScanning = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + paymentReceiptEmail, + responseMetadata, + paymentProducts, + payments, + fieldIdToQuarantineKeyMap, +}: SubmitStorageFormWithVirusScanningArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionWithVirusScanningFormData( + { + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: VIRUS_SCANNER_SUBMISSION_VERSION, + }, + fieldIdToQuarantineKeyMap, + ) + + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + // TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved export const submitEmailModeFormWithFetch = async ({ formFields, diff --git a/frontend/src/features/public-form/mutations.ts b/frontend/src/features/public-form/mutations.ts index 714cda74ae..bf038bedf7 100644 --- a/frontend/src/features/public-form/mutations.ts +++ b/frontend/src/features/public-form/mutations.ts @@ -10,6 +10,7 @@ import { useToast } from '~hooks/useToast' import { useStorePrefillQuery } from './hooks/useStorePrefillQuery' import { + FieldIdToQuarantineKeyType, getAttachmentPresignedPostData, getPublicFormAuthRedirectUrl, logoutPublicForm, @@ -22,6 +23,7 @@ import { SubmitStorageFormClearArgs, submitStorageModeClearForm, submitStorageModeClearFormWithFetch, + submitStorageModeClearFormWithVirusScanning, submitStorageModeForm, submitStorageModeFormWithFetch, uploadAttachmentToQuarantine, @@ -123,34 +125,59 @@ export const usePublicFormMutations = ( const submitStorageModeClearFormWithVirusScanningMutation = useMutation( (args: Omit) => { // Step 1: Get presigned post data for all attachment fields - return getAttachmentPresignedPostData({ ...args, formId }).then( - // Step 2: Upload attachments to quarantine bucket asynchronously - (presignedPostDataList) => - Promise.all( - presignedPostDataList.map(async (presignedPostData) => { - const attachmentFile = args.formInputs[presignedPostData.id] - - // Check if response is a File object (from an attachment field) - if (!(attachmentFile instanceof File)) - throw new Error('Field is not attachment') - - const uploadResponse = await uploadAttachmentToQuarantine( - presignedPostData.presignedPostData, - attachmentFile, - ) - - // // If status code is not 200-299, throw error - if (uploadResponse.status < 200 || uploadResponse.status > 299) - throw new Error( - `Attachment upload failed - ${uploadResponse.statusText}`, - ) - - return { - fieldId: presignedPostData.id, - uploadResponse, - } - }), - ), + return ( + getAttachmentPresignedPostData({ ...args, formId }) + .then( + // Step 2: Upload attachments to quarantine bucket asynchronously + (fieldToPresignedPostDataMap) => + Promise.all( + fieldToPresignedPostDataMap.map( + async (fieldToPresignedPostData) => { + const attachmentFile = + args.formInputs[fieldToPresignedPostData.id] + + // Check if response is a File object (from an attachment field) + if (!(attachmentFile instanceof File)) + throw new Error('Field is not attachment') + + const uploadResponse = await uploadAttachmentToQuarantine( + fieldToPresignedPostData.presignedPostData, + attachmentFile, + ) + + // If status code is not 200-299, throw error + if ( + uploadResponse.status < 200 || + uploadResponse.status > 299 + ) + throw new Error( + `Attachment upload failed - ${uploadResponse.statusText}`, + ) + + const quarantineBucketKey = + fieldToPresignedPostData.presignedPostData.fields.key + + if (!quarantineBucketKey) + throw new Error( + 'key is not defined in presigned post data', + ) + + return { + fieldId: fieldToPresignedPostData.id, + quarantineBucketKey, + } as FieldIdToQuarantineKeyType + }, + ), + ), + ) + // Step 3: Submit form with keys to quarantine bucket attachments + .then((fieldIdToQuarantineKeyMap) => { + return submitStorageModeClearFormWithVirusScanning({ + ...args, + fieldIdToQuarantineKeyMap, + formId, + }) + }) ) }, ) diff --git a/frontend/src/features/public-form/utils/createSubmission.ts b/frontend/src/features/public-form/utils/createSubmission.ts index 3bf025462f..ba6a16dba9 100644 --- a/frontend/src/features/public-form/utils/createSubmission.ts +++ b/frontend/src/features/public-form/utils/createSubmission.ts @@ -22,6 +22,8 @@ import fileArrayBuffer from '~/utils/fileArrayBuffer' import formsgSdk from '~utils/formSdk' import { AttachmentFieldSchema, FormFieldValues } from '~templates/Field' +import { FieldIdToQuarantineKeyType } from '../PublicFormService' + import { transformInputsToOutputs } from './inputTransformation' import { validateResponses } from './validateResponses' @@ -121,6 +123,56 @@ export const createClearSubmissionFormData = ( return formData } +export const createClearSubmissionWithVirusScanningFormData = ( + formDataArgs: + | CreateEmailSubmissionFormDataArgs + | CreateStorageSubmissionFormDataArgs, + fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[], +) => { + const { formFields, formInputs, ...formDataArgsRest } = formDataArgs + const responses = createResponsesArray(formFields, formInputs).map( + (response) => { + if (response.fieldType === BasicField.Attachment && response.answer) { + const fieldIdToQuarantineKeyEntry = fieldIdToQuarantineKeyMap.find( + (v) => v.fieldId === response._id, + ) + if (!fieldIdToQuarantineKeyEntry) + throw new Error( + `Attachment response with fieldId ${response._id} not found among attachments uploaded to quarantine bucket`, + ) + response.answer = fieldIdToQuarantineKeyEntry.quarantineBucketKey + } + return response + }, + ) + const attachments = getAttachmentsMap(formFields, formInputs) + + // Convert content to FormData object. + const formData = new FormData() + formData.append( + 'body', + JSON.stringify({ + responses, + ...formDataArgsRest, + }), + ) + + if (!isEmpty(attachments)) { + forOwn(attachments, (attachment, fieldId) => { + if (attachment) { + formData.append( + attachment.name, + // Set content as empty array buffer. + new File([], attachment.name, { type: attachment.type }), + fieldId, + ) + } + }) + } + + return formData +} + const createResponsesArray = ( formFields: FormFieldDto[], formInputs: FormFieldValues,