Skip to content

Commit f27da56

Browse files
committed
- Moved 2854-file-encoding-with-package over to new branch
1 parent 2ae423c commit f27da56

File tree

6 files changed

+127
-60
lines changed

6 files changed

+127
-60
lines changed

tdrs-frontend/package-lock.json

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tdrs-frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@uswds/uswds": "3.10.0",
1212
"axios": "^1.7.7",
1313
"classnames": "^2.5.1",
14+
"detect-file-encoding-and-language": "^2.4.0",
1415
"file-type-checker": "^1.1.2",
1516
"history": "^5.3.0",
1617
"include-media": "^2.0.0",

tdrs-frontend/src/actions/reports.js

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import axios from 'axios'
44
import axiosInstance from '../axios-instance'
55
import { logErrorToServer } from '../utils/eventLogger'
66
import removeFileInputErrorState from '../utils/removeFileInputErrorState'
7-
import { fileUploadSections } from '../reducers/reports'
87

98
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL
109

tdrs-frontend/src/components/FileUpload/FileUpload.jsx

+87-42
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react'
22
import PropTypes from 'prop-types'
33
import { useDispatch, useSelector } from 'react-redux'
44
import fileTypeChecker from 'file-type-checker'
5+
import languageEncoding from 'detect-file-encoding-and-language'
56

67
import {
78
clearError,
@@ -34,6 +35,87 @@ const INVALID_EXT_ERROR = (
3435
</>
3536
)
3637

38+
// The package author suggests using a minimum of 500 words to determine the encoding. However, datafiles don't have
39+
// "words" so we're using bytes instead to determine the encoding. See: https://www.npmjs.com/package/detect-file-encoding-and-language
40+
const MIN_BYTES = 500
41+
42+
/* istanbul ignore next */
43+
const tryGetUTF8EncodedFile = async function (fileBytes, file) {
44+
// Create a small view of the file to determine the encoding.
45+
const btyesView = new Uint8Array(fileBytes.slice(0, MIN_BYTES))
46+
const blobView = new Blob([btyesView], { type: 'text/plain' })
47+
try {
48+
const fileInfo = await languageEncoding(blobView)
49+
const bom = btyesView.slice(0, 3)
50+
const hasBom = bom[0] === 0xef && bom[1] === 0xbb && bom[2] === 0xbf
51+
if ((fileInfo && fileInfo.encoding !== 'UTF-8') || hasBom) {
52+
const utf8Encoder = new TextEncoder()
53+
const decoder = new TextDecoder(fileInfo.encoding)
54+
const decodedString = decoder.decode(
55+
hasBom ? fileBytes.slice(3) : fileBytes
56+
)
57+
const utf8Bytes = utf8Encoder.encode(decodedString)
58+
return new File([utf8Bytes], file.name, file.options)
59+
}
60+
return file
61+
} catch (error) {
62+
// This is a last ditch fallback to ensure consistent functionality and also allows the unit tests to work in the
63+
// same way they did before this change. When the unit tests (i.e. Node environment) call `languageEncoding` it
64+
// expects a Buffer/string/URL object. When the browser calls `languageEncoding`, it expects a Blob/File object.
65+
// There is not a convenient way or universal object to handle both cases. Thus, when the tests run the call to
66+
// `languageEncoding`, it raises an exception and we return the file as is which is then dispatched as it would
67+
// have been before this change.
68+
console.error('Caught error while handling file encoding. Error:', error)
69+
return file
70+
}
71+
}
72+
73+
const load = (file, section, input, dropTarget, dispatch) => {
74+
const filereader = new FileReader()
75+
const types = ['png', 'gif', 'jpeg']
76+
77+
return new Promise((resolve, reject) => {
78+
filereader.onerror = () => {
79+
filereader.abort()
80+
reject()
81+
}
82+
83+
filereader.onload = () => {
84+
const re = /(\.txt|\.ms\d{2}|\.ts\d{2,3})$/i
85+
if (!re.exec(file.name)) {
86+
dispatch({
87+
type: FILE_EXT_ERROR,
88+
payload: {
89+
error: { message: INVALID_EXT_ERROR },
90+
section,
91+
},
92+
})
93+
reject()
94+
return
95+
}
96+
97+
const isImg = fileTypeChecker.validateFileType(filereader.result, types)
98+
99+
if (isImg) {
100+
createFileInputErrorState(input, dropTarget)
101+
102+
dispatch({
103+
type: SET_FILE_ERROR,
104+
payload: {
105+
error: { message: INVALID_FILE_ERROR },
106+
section,
107+
},
108+
})
109+
reject()
110+
return
111+
}
112+
113+
resolve({ result: filereader.result })
114+
}
115+
filereader.readAsArrayBuffer(file)
116+
})
117+
}
118+
37119
function FileUpload({ section, setLocalAlertState }) {
38120
// e.g. 'Aggregate Case Data' => 'aggregate-case-data'
39121
// The set of uploaded files in our Redux state
@@ -86,7 +168,7 @@ function FileUpload({ section, setLocalAlertState }) {
86168
}
87169
const inputRef = useRef(null)
88170

89-
const validateAndUploadFile = (event) => {
171+
const validateAndUploadFile = async (event) => {
90172
setLocalAlertState({
91173
active: false,
92174
type: null,
@@ -101,51 +183,14 @@ function FileUpload({ section, setLocalAlertState }) {
101183
dispatch(clearError({ section }))
102184
dispatch(clearFile({ section }))
103185

104-
// Get the the first 4 bytes of the file with which to check file signatures
105-
const blob = file.slice(0, 4)
106-
107186
const input = inputRef.current
108187
const dropTarget = inputRef.current.parentNode
109188

110-
const filereader = new FileReader()
111-
112-
const types = ['png', 'gif', 'jpeg']
113-
filereader.onload = () => {
114-
const re = /(\.txt|\.ms\d{2}|\.ts\d{2,3})$/i
115-
if (!re.exec(file.name)) {
116-
dispatch({
117-
type: FILE_EXT_ERROR,
118-
payload: {
119-
error: { message: INVALID_EXT_ERROR },
120-
section,
121-
},
122-
})
123-
return
124-
}
125-
126-
const isImg = fileTypeChecker.validateFileType(filereader.result, types)
127-
128-
if (isImg) {
129-
createFileInputErrorState(input, dropTarget)
130-
131-
dispatch({
132-
type: SET_FILE_ERROR,
133-
payload: {
134-
error: { message: INVALID_FILE_ERROR },
135-
section,
136-
},
137-
})
138-
} else {
139-
dispatch(
140-
upload({
141-
section,
142-
file,
143-
})
144-
)
145-
}
146-
}
189+
const { result } = await load(file, section, input, dropTarget, dispatch)
147190

148-
filereader.readAsArrayBuffer(blob)
191+
// Get the correctly encoded file
192+
const encodedFile = await tryGetUTF8EncodedFile(result, file)
193+
dispatch(upload({ file: encodedFile, section }))
149194
}
150195

151196
return (

tdrs-frontend/src/components/UploadReport/UploadReport.test.js

+23-17
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react'
22
import { thunk } from 'redux-thunk'
33
import { Provider } from 'react-redux'
44
import configureStore from 'redux-mock-store'
5-
import { fireEvent, render } from '@testing-library/react'
5+
import { fireEvent, render, waitFor } from '@testing-library/react'
66
import axios from 'axios'
77

88
import { v4 as uuidv4 } from 'uuid'
@@ -59,7 +59,7 @@ describe('UploadReport', () => {
5959
expect(inputs.length).toEqual(4)
6060
})
6161

62-
it('should dispatch the `clearError` and `upload` actions when submit button is clicked', () => {
62+
it('should dispatch the `clearError` and `upload` actions when submit button is clicked', async () => {
6363
const store = mockStore(initialState)
6464
const origDispatch = store.dispatch
6565
store.dispatch = jest.fn(origDispatch)
@@ -74,16 +74,18 @@ describe('UploadReport', () => {
7474

7575
const newFile = new File(['test'], 'test.txt', { type: 'text/plain' })
7676

77-
fireEvent.change(fileInput, {
78-
target: {
79-
files: [newFile],
80-
},
77+
await waitFor(() => {
78+
fireEvent.change(fileInput, {
79+
target: {
80+
files: [newFile],
81+
},
82+
})
8183
})
8284

8385
expect(store.dispatch).toHaveBeenCalledTimes(2)
8486
expect(container.querySelectorAll('.has-invalid-file').length).toBe(0)
8587
})
86-
it('should prevent upload of file with invalid extension', () => {
88+
it('should prevent upload of file with invalid extension', async () => {
8789
const store = mockStore(initialState)
8890
const origDispatch = store.dispatch
8991
store.dispatch = jest.fn(origDispatch)
@@ -101,10 +103,12 @@ describe('UploadReport', () => {
101103
})
102104

103105
expect(container.querySelectorAll('.has-invalid-file').length).toBe(0)
104-
fireEvent.change(fileInput, {
105-
target: {
106-
files: [newFile],
107-
},
106+
await waitFor(() => {
107+
fireEvent.change(fileInput, {
108+
target: {
109+
files: [newFile],
110+
},
111+
})
108112
})
109113

110114
expect(store.dispatch).toHaveBeenCalledTimes(2)
@@ -223,7 +227,7 @@ describe('UploadReport', () => {
223227
expect(formGroup.classList.contains('usa-form-group--error')).toBeFalsy()
224228
})
225229

226-
it('should clear input value if there is an error', () => {
230+
it('should clear input value if there is an error', async () => {
227231
const store = mockStore(initialState)
228232
axios.post.mockImplementationOnce(() =>
229233
Promise.resolve({ data: { id: 1 } })
@@ -240,11 +244,13 @@ describe('UploadReport', () => {
240244
const newFile = new File(['test'], 'test.txt', { type: 'text/plain' })
241245
const fileList = [newFile]
242246

243-
fireEvent.change(fileInput, {
244-
target: {
245-
name: 'Active Case Data',
246-
files: fileList,
247-
},
247+
await waitFor(() => {
248+
fireEvent.change(fileInput, {
249+
target: {
250+
name: 'Active Case Data',
251+
files: fileList,
252+
},
253+
})
248254
})
249255

250256
expect(fileInput.value).toStrictEqual('')

tdrs-frontend/src/setupTests.js

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import 'jest-enzyme'
77
import Enzyme from 'enzyme'
88
import Adapter from '@cfaester/enzyme-adapter-react-18'
99
import startMirage from './mirage'
10+
import { TextEncoder, TextDecoder } from 'util'
11+
12+
Object.assign(global, { TextDecoder, TextEncoder })
1013

1114
Enzyme.configure({ adapter: new Adapter() })
1215

0 commit comments

Comments
 (0)