Skip to content

Commit 98be619

Browse files
authored
chore: Add checksum for file uploads (#6266)
initial setup
1 parent 3b22815 commit 98be619

File tree

9 files changed

+205
-12
lines changed

9 files changed

+205
-12
lines changed

app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export async function submitForm(
3131
values: Responses,
3232
language: string,
3333
formRecordOrId: PublicFormRecord | string,
34-
captchaToken?: string | undefined
34+
captchaToken?: string | undefined,
35+
fileChecksums?: Record<string, string>
3536
): Promise<{
3637
id: string;
3738
submissionId?: string;
@@ -114,6 +115,7 @@ export async function submitForm(
114115
securityAttribute: template.securityAttribute,
115116
formId,
116117
language,
118+
fileChecksums,
117119
});
118120

119121
sendNotifications(formId, template.form.titleEn, template.form.titleFr);

app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import axios, { AxiosError, AxiosProgressEvent } from "axios";
44
import { Responses, FileInputResponse, FileInput } from "@gcforms/types";
55
import { FileUploadError } from "../client/exceptions";
66
import { isMimeTypeValid } from "@gcforms/core";
7+
import { generateFileChecksums } from "@lib/utils/fileChecksum";
78

89
const isFileInput = (response: unknown): response is FileInput => {
910
return (
@@ -63,7 +64,11 @@ export const copyObjectExcludingFileContent = (
6364
return filteredState as unknown as T;
6465
};
6566
filterFileContent(originalObject, formValuesWithoutFileContent);
66-
return { formValuesWithoutFileContent, fileObjsRef };
67+
68+
// Calculate checksums for all files key / value pairs
69+
const fileChecksums = generateFileChecksums(fileObjsRef);
70+
71+
return { formValuesWithoutFileContent, fileObjsRef, fileChecksums };
6772
};
6873

6974
export const uploadFile = async (

app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/invokeSubmissionLambda.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,25 @@ export const invokeSubmissionLambda = async (
99
formID: string,
1010
fields: Responses,
1111
language: string,
12-
securityAttribute: string
12+
securityAttribute: string,
13+
fileChecksums?: Record<string, string>
1314
): Promise<{
1415
submissionId: string;
1516
fileURLMap?: SignedURLMap;
1617
}> => {
1718
try {
19+
const payload = {
20+
formID,
21+
language,
22+
responses: fields,
23+
securityAttribute,
24+
...(fileChecksums && Object.keys(fileChecksums).length > 0 && { fileChecksums }),
25+
};
26+
1827
const lambdaInvokeResponse = await lambdaClient.send(
1928
new InvokeCommand({
2029
FunctionName: "Submission",
21-
Payload: JSON.stringify({
22-
formID,
23-
language,
24-
responses: fields,
25-
securityAttribute,
26-
}),
30+
Payload: JSON.stringify(payload),
2731
})
2832
);
2933

app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/processFormData.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ type ProcessFormDataParams = {
1010
securityAttribute?: string;
1111
formId: string;
1212
language?: string;
13+
fileChecksums?: Record<string, string>;
1314
};
1415

1516
export const processFormData = async ({
1617
responses,
1718
securityAttribute,
1819
formId,
1920
language,
21+
fileChecksums,
2022
}: ProcessFormDataParams): Promise<{
2123
submissionId: string;
2224
fileURLMap?: SignedURLMap;
@@ -56,7 +58,8 @@ export const processFormData = async ({
5658
form.id,
5759
responses,
5860
language ? language : "en",
59-
securityAttribute ? securityAttribute : "Protected A"
61+
securityAttribute ? securityAttribute : "Protected A",
62+
fileChecksums
6063
);
6164

6265
logMessage.info(`Response submitted for Form ID: ${form.id}`);

components/clientComponents/forms/Form/Form.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ export const Form = withFormik<FormProps, Responses>({
300300
);
301301

302302
// Extract file content from formValues so they are not part of the submission call to the submit action
303-
const { formValuesWithoutFileContent, fileObjsRef } =
303+
const { formValuesWithoutFileContent, fileObjsRef, fileChecksums } =
304304
copyObjectExcludingFileContent(formValues);
305305

306306
let submitProgress = 0;
@@ -328,7 +328,8 @@ export const Form = withFormik<FormProps, Responses>({
328328
formValuesWithoutFileContent,
329329
formikBag.props.language,
330330
formikBag.props.formRecord.id,
331-
formikBag.props.captchaToken?.current
331+
formikBag.props.captchaToken?.current,
332+
fileChecksums
332333
);
333334

334335
clearInterval(progressInterval);

lib/utils/fileChecksum.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createHash } from "crypto";
2+
import { FileInput } from "@gcforms/types";
3+
import { logMessage } from "@lib/logger";
4+
5+
export function calculateMD5Checksum(content: ArrayBuffer): string {
6+
const buffer = Buffer.from(content);
7+
const hash = createHash("md5");
8+
hash.update(buffer);
9+
return hash.digest("base64");
10+
}
11+
12+
export function calculateFileChecksum(file: FileInput): string {
13+
// Ensure file content is available
14+
if (!file.content) {
15+
throw new Error(`Unable to calculate checksum for file ${file.name}: no content available`);
16+
}
17+
return calculateMD5Checksum(file.content);
18+
}
19+
20+
/**
21+
* Take our fileObjsRefs and generate file checksums map for each file
22+
* @param fileObjsRef - Map of file IDs to FileInput objects
23+
* @returns Map of file IDs to their MD5 checksums
24+
*/
25+
export function generateFileChecksums(
26+
fileObjsRef: Record<string, FileInput>
27+
): Record<string, string> {
28+
const checksums: Record<string, string> = {};
29+
30+
for (const [fileId, file] of Object.entries(fileObjsRef)) {
31+
try {
32+
checksums[fileId] = calculateFileChecksum(file);
33+
} catch (error) {
34+
logMessage.warn(`Failed to calculate checksum for file ${file.name}: ${error}`);
35+
// Continue with other files, don't fail the entire process
36+
}
37+
}
38+
39+
return checksums;
40+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { calculateMD5Checksum, calculateFileChecksum, generateFileChecksums } from '../fileChecksum';
3+
import { FileInput } from '@gcforms/types';
4+
import { readFileSync } from 'fs';
5+
import { join } from 'path';
6+
7+
describe('fileChecksum', () => {
8+
// Helper function to load CSV fixtures
9+
const loadCsvFixture = (filename: string): ArrayBuffer => {
10+
const csvPath = join(__dirname, 'fixtures', filename);
11+
return readFileSync(csvPath).buffer;
12+
};
13+
14+
describe('calculateMD5Checksum', () => {
15+
it('should calculate MD5 checksum for file content', () => {
16+
const content = loadCsvFixture('sample.csv');
17+
const checksum = calculateMD5Checksum(content);
18+
19+
// This checksum should be consistent for the sample.csv file
20+
expect(checksum).toBeTruthy();
21+
expect(typeof checksum).toBe('string');
22+
});
23+
24+
it('should return different checksums for different content', () => {
25+
const content1 = loadCsvFixture('sample.csv');
26+
const content2 = new TextEncoder().encode('Different content').buffer;
27+
28+
const checksum1 = calculateMD5Checksum(content1);
29+
const checksum2 = calculateMD5Checksum(content2);
30+
31+
expect(checksum1).not.toBe(checksum2);
32+
});
33+
34+
it('should return same checksum for same content', () => {
35+
const content1 = loadCsvFixture('sample.csv');
36+
const content2 = loadCsvFixture('sample-copy.csv');
37+
38+
const checksum1 = calculateMD5Checksum(content1);
39+
const checksum2 = calculateMD5Checksum(content2);
40+
41+
expect(checksum1).toBe(checksum2);
42+
});
43+
});
44+
45+
describe('calculateFileChecksum', () => {
46+
it('should calculate checksum for FileInput object', () => {
47+
const csvContent = loadCsvFixture('sample.csv');
48+
const file: FileInput = {
49+
name: 'sample.csv',
50+
size: csvContent.byteLength,
51+
content: csvContent,
52+
};
53+
54+
const checksum = calculateFileChecksum(file);
55+
expect(checksum).toBeTruthy();
56+
expect(typeof checksum).toBe('string');
57+
});
58+
59+
it('should throw error for file without content', () => {
60+
const file = {
61+
name: 'test.txt',
62+
size: 0,
63+
content: null,
64+
} as unknown as FileInput;
65+
66+
expect(() => calculateFileChecksum(file)).toThrow(
67+
'Unable to calculate checksum for file test.txt: no content available'
68+
);
69+
});
70+
});
71+
72+
describe('generateFileChecksums', () => {
73+
it('should generate checksums for multiple files', () => {
74+
const csvContent1 = loadCsvFixture('sample.csv');
75+
const csvContent2 = loadCsvFixture('sample-copy.csv');
76+
77+
const fileObjsRef = {
78+
'file1': {
79+
name: 'sample.csv',
80+
size: csvContent1.byteLength,
81+
content: csvContent1,
82+
} as FileInput,
83+
'file2': {
84+
name: 'sample-copy.csv',
85+
size: csvContent2.byteLength,
86+
content: csvContent2,
87+
} as FileInput,
88+
};
89+
90+
const checksums = generateFileChecksums(fileObjsRef);
91+
92+
expect(checksums).toHaveProperty('file1');
93+
expect(checksums).toHaveProperty('file2');
94+
// Both files have identical content, so checksums should be the same
95+
expect(checksums.file1).toBe(checksums.file2);
96+
});
97+
98+
it('should handle empty file objects', () => {
99+
const checksums = generateFileChecksums({});
100+
expect(checksums).toEqual({});
101+
});
102+
103+
it('should continue processing when one file fails', () => {
104+
const csvContent = loadCsvFixture('sample.csv');
105+
106+
const fileObjsRef = {
107+
'file1': {
108+
name: 'sample.csv',
109+
size: csvContent.byteLength,
110+
content: csvContent,
111+
} as FileInput,
112+
'file2': {
113+
name: 'broken.csv',
114+
size: 0,
115+
content: null,
116+
} as unknown as FileInput,
117+
};
118+
119+
const checksums = generateFileChecksums(fileObjsRef);
120+
121+
expect(checksums).toHaveProperty('file1');
122+
expect(checksums).not.toHaveProperty('file2');
123+
expect(typeof checksums.file1).toBe('string');
124+
});
125+
});
126+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name,email,age,department
2+
John Doe,[email protected],30,Engineering
3+
Jane Smith,[email protected],25,Marketing
4+
Bob Johnson,[email protected],35,Sales
5+
Alice Brown,[email protected],28,HR
6+
Charlie Wilson,[email protected],32,Finance
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name,email,age,department
2+
John Doe,[email protected],30,Engineering
3+
Jane Smith,[email protected],25,Marketing
4+
Bob Johnson,[email protected],35,Sales
5+
Alice Brown,[email protected],28,HR
6+
Charlie Wilson,[email protected],32,Finance

0 commit comments

Comments
 (0)