feat: validate uploaded image files (#3797)

* chore: upgrade react-dropzone to latest
* feat: add basic image validation checks
* feat: remove unused image input properties
* feat: add compression to image input component
* chore: remove unused compressor plugin from file input component
* fix: remove on img clicked property from image converter list
* test: add feature test

---------

Co-authored-by: Ben Furber <ben.furber@googlemail.com>
This commit is contained in:
Sean Thompson
2024-09-02 17:24:07 +01:00
committed by GitHub
parent ab4b347046
commit e0a1ba899a
10 changed files with 160 additions and 45 deletions

View File

@@ -86,6 +86,7 @@
"@uppy/file-input": "^3.1.2", "@uppy/file-input": "^3.1.2",
"@uppy/progress-bar": "^3.1.1", "@uppy/progress-bar": "^3.1.1",
"@uppy/react": "^3.3.1", "@uppy/react": "^3.3.1",
"compressorjs": "^1.2.1",
"countries-list": "^2.6.1", "countries-list": "^2.6.1",
"date-fns": "^3.3.0", "date-fns": "^3.3.0",
"debounce": "^1.2.0", "debounce": "^1.2.0",
@@ -108,7 +109,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-country-flag": "^3.1.0", "react-country-flag": "^3.1.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-dropzone": "^10.1.10", "react-dropzone": "^14.2.3",
"react-final-form": "6.5.3", "react-final-form": "6.5.3",
"react-final-form-arrays": "^3.1.3", "react-final-form-arrays": "^3.1.3",
"react-foco": "^1.3.1", "react-foco": "^1.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -43,7 +43,7 @@ describe('[Settings]', () => {
cy.get('[data-cy="Confirm.modal: Modal"]').should('be.visible') cy.get('[data-cy="Confirm.modal: Modal"]').should('be.visible')
}) })
it('[Edit a new profile]', () => { it.only('[Edit a new profile]', () => {
const country = 'Brazil' const country = 'Brazil'
const userImage = 'avatar' const userImage = 'avatar'
const displayName = 'settings_member_new' const displayName = 'settings_member_new'
@@ -79,8 +79,17 @@ describe('[Settings]', () => {
description, description,
}) })
cy.step('Can add avatar only') cy.step('Errors if trying to upload invalid image')
cy.get(`[data-cy=userImage]`)
.find(':file')
.attachFile(`images/file.random`)
cy.get('[data-cy=ImageUploadError]').should('be.visible')
cy.get('[data-cy=ImageUploadError-Button]').click()
cy.step('Can add avatar')
cy.setSettingImage(userImage, 'userImage') cy.setSettingImage(userImage, 'userImage')
cy.step("Can't add cover image")
cy.get('[data-cy=coverImages]').should('not.exist') cy.get('[data-cy=coverImages]').should('not.exist')
cy.setSettingAddContactLink({ cy.setSettingAddContactLink({

View File

@@ -1,6 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Compressor from '@uppy/compressor'
import Uppy from '@uppy/core' import Uppy from '@uppy/core'
import { DashboardModal } from '@uppy/react' import { DashboardModal } from '@uppy/react'
import { Button, DownloadStaticFile } from 'oa-components' import { Button, DownloadStaticFile } from 'oa-components'
@@ -25,10 +24,8 @@ interface IState {
} }
export const FileInput = (props: IProps) => { export const FileInput = (props: IProps) => {
const [state, setState] = useState<IState>({ open: false }) const [state, setState] = useState<IState>({ open: false })
const [uppy] = useState(() => const [uppy] = useState(
new Uppy({ ...UPPY_CONFIG, onBeforeUpload: () => uploadTriggered() }).use( () => new Uppy({ ...UPPY_CONFIG, onBeforeUpload: () => uploadTriggered() }),
Compressor,
),
) )
useEffect(() => { useEffect(() => {

View File

@@ -6,11 +6,9 @@ import type { IConvertedFileMeta } from 'src/types'
interface IProps { interface IProps {
file: File file: File
onImgConverted: (meta: IConvertedFileMeta) => void onImgConverted: (meta: IConvertedFileMeta) => void
onImgClicked: (meta: IConvertedFileMeta) => void
} }
interface IState { interface IState {
convertedFile?: IConvertedFileMeta convertedFile?: IConvertedFileMeta
openLightbox?: boolean
} }
const _generateFileMeta = (c: File) => { const _generateFileMeta = (c: File) => {
@@ -67,7 +65,6 @@ export const ImageConverter = (props: IProps) => {
borderRadius: 1, borderRadius: 1,
}} }}
id="preview" id="preview"
onClick={() => props.onImgClicked(convertedFile)}
/> />
) )
} }

View File

@@ -16,7 +16,6 @@ export const ImageConverterList = (props: IProps) => {
key={file.name} key={file.name}
file={file} file={file}
onImgConverted={(meta) => handleConvertedFileChange(meta, index)} onImgConverted={(meta) => handleConvertedFileChange(meta, index)}
onImgClicked={() => null}
/> />
) )
})} })}

View File

@@ -1,23 +1,21 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import Dropzone from 'react-dropzone' import Dropzone from 'react-dropzone'
import { Button } from 'oa-components' import { Button, Modal } from 'oa-components'
import { Box, Image } from 'theme-ui' import { logger } from 'src/logger'
import { Box, Flex, Image, Text } from 'theme-ui'
import { compressImage } from './compressImage'
import { DeleteImage } from './DeleteImage' import { DeleteImage } from './DeleteImage'
import { getPresentFiles } from './getPresentFiles' import { getPresentFiles } from './getPresentFiles'
import { ImageConverterList } from './ImageConverterList' import { ImageConverterList } from './ImageConverterList'
import { ImageInputWrapper } from './ImageInputWrapper' import { ImageInputWrapper } from './ImageInputWrapper'
import { imageValid } from './imageValid'
import { setSrc } from './setSrc' import { setSrc } from './setSrc'
import type { IConvertedFileMeta } from 'src/types' import type { IConvertedFileMeta } from 'src/types'
import type { ThemeUIStyleObject } from 'theme-ui' import type { ThemeUIStyleObject } from 'theme-ui'
import type { IInputValue, IMultipleInputValue, IValue } from './types' import type { IInputValue, IMultipleInputValue, IValue } from './types'
/*
This component takes multiple image using filepicker and resized clientside
Note, typings not available for client-compress so find full options here:
https://github.com/davejm/client-compress
*/
type IFileMeta = IConvertedFileMeta[] | IConvertedFileMeta | null type IFileMeta = IConvertedFileMeta[] | IConvertedFileMeta | null
interface IProps { interface IProps {
@@ -25,7 +23,6 @@ interface IProps {
imageDisplaySx?: ThemeUIStyleObject | undefined imageDisplaySx?: ThemeUIStyleObject | undefined
value?: IValue value?: IValue
hasText?: boolean hasText?: boolean
multiple?: boolean
dataTestId?: string dataTestId?: string
} }
@@ -33,15 +30,36 @@ export const ImageInput = (props: IProps) => {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const prevPropsValue = useRef<IInputValue | IMultipleInputValue>() const prevPropsValue = useRef<IInputValue | IMultipleInputValue>()
const { dataTestId, imageDisplaySx, multiple, onFilesChange, value } = props const { dataTestId, imageDisplaySx, onFilesChange, value } = props
const [inputFiles, setInputFiles] = useState<File[]>([]) const [inputFiles, setInputFiles] = useState<File[]>([])
const [convertedFiles, setConvertedFiles] = useState<IConvertedFileMeta[]>([]) const [convertedFiles, setConvertedFiles] = useState<IConvertedFileMeta[]>([])
const [presentFiles, setPresentFiles] = useState<IMultipleInputValue>( const [presentFiles, setPresentFiles] = useState<IMultipleInputValue>(
getPresentFiles(value), getPresentFiles(value),
) )
const [isImageCorrupt, setIsImageCorrupt] = useState(false)
const [showErrorModal, setShowErrorModal] = useState(false)
const onDrop = (inputFiles) => { const onDrop = async (selectedImage) => {
setInputFiles(inputFiles) try {
await imageValid(selectedImage[0])
setIsImageCorrupt(false)
try {
const compressedImage = await compressImage(selectedImage[0])
selectedImage[0] = compressedImage
} catch (compressionError) {
logger.error(
'Image compression failed, using original image: ',
compressionError,
)
}
setInputFiles(selectedImage)
} catch (validationError) {
setIsImageCorrupt(true)
setShowErrorModal(true)
}
} }
const handleConvertedFileChange = (newFile: IConvertedFileMeta, index) => { const handleConvertedFileChange = (newFile: IConvertedFileMeta, index) => {
@@ -49,8 +67,7 @@ export const ImageInput = (props: IProps) => {
nextFiles[index] = newFile nextFiles[index] = newFile
setConvertedFiles(convertedFiles) setConvertedFiles(convertedFiles)
const value = props.multiple ? convertedFiles : convertedFiles[0] props.onFilesChange(convertedFiles[0])
props.onFilesChange(value)
} }
const handleImageDelete = (event: Event) => { const handleImageDelete = (event: Event) => {
@@ -76,13 +93,18 @@ export const ImageInput = (props: IProps) => {
return ( return (
<Box p={0} sx={imageDisplaySx ? imageDisplaySx : { height: '100%' }}> <Box p={0} sx={imageDisplaySx ? imageDisplaySx : { height: '100%' }}>
<Dropzone accept="image/*" multiple={multiple} onDrop={onDrop}> <Dropzone
accept={{
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.svg', '.webp'],
}}
multiple={false}
onDrop={onDrop}
>
{({ getRootProps, getInputProps, rootRef }) => ( {({ getRootProps, getInputProps, rootRef }) => (
<ImageInputWrapper <ImageInputWrapper
{...getRootProps()}
ref={rootRef} ref={rootRef}
hasUploadedImg={showUploadedImg} hasUploadedImg={showUploadedImg}
sx={{ width: '100%', height: '100%' }}
{...getRootProps()}
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -98,19 +120,50 @@ export const ImageInput = (props: IProps) => {
handleConvertedFileChange={handleConvertedFileChange} handleConvertedFileChange={handleConvertedFileChange}
/> />
)} )}
{!hasImages && ( {!hasImages && (
<Button small variant="outline" icon="image" type="button"> <Button small variant="outline" icon="image" type="button">
Upload Upload
</Button> </Button>
)} )}
{hasImages && ( {hasImages && (
<DeleteImage onClick={(event) => handleImageDelete(event)} /> <DeleteImage onClick={(event) => handleImageDelete(event)} />
)} )}
</ImageInputWrapper> </ImageInputWrapper>
)} )}
</Dropzone> </Dropzone>
<Modal
width={600}
isOpen={showErrorModal}
onDidDismiss={() => setShowErrorModal(false)}
>
{isImageCorrupt && (
<Flex
data-cy="ImageUploadError"
mt={[1, 1, 1]}
sx={{
flexDirection: 'column',
justifyContent: 'space-between',
gap: '20px',
}}
>
<Text>
The uploaded image appears to be corrupted or a type we don't
accept.
</Text>
<Text>
Check your image is valid and one of the following formats: jpeg,
jpg, png, gif, heic, svg or webp.
</Text>
<Button
data-cy="ImageUploadError-Button"
sx={{ marginTop: '20px', justifyContent: 'center' }}
onClick={() => setShowErrorModal(false)}
>
Try uploading something else
</Button>
</Flex>
)}
</Modal>
</Box> </Box>
) )
} }

View File

@@ -0,0 +1,20 @@
import Compressor from 'compressorjs'
export const compressImage = (image: File): Promise<File> => {
return new Promise((resolve, reject) => {
if (!image) {
reject()
return
}
new Compressor(image, {
quality: 0.6, // 0 to 1
success: (compressed) => {
resolve(compressed as File)
},
error: (err) => {
reject(err)
},
})
})
}

View File

@@ -0,0 +1,38 @@
// Basic check using the filereader api to see whether we can create a displayable image
// If this fails then there is a problem with the file
export const imageValid = (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
if (!file) {
reject()
return
}
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const img = document.createElement('img')
img.onload = () => {
// Image loaded successfully
img.remove()
resolve()
}
img.onerror = () => {
// Image failed to load (possibly corrupted)
img.remove()
reject()
}
img.src = e.target?.result as string
}
reader.onerror = () => {
// Error reading file. It might be corrupted.
reject()
}
reader.readAsDataURL(file)
})
}

View File

@@ -10979,7 +10979,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"attr-accept@npm:^2.0.0": "attr-accept@npm:^2.2.2":
version: 2.2.2 version: 2.2.2
resolution: "attr-accept@npm:2.2.2" resolution: "attr-accept@npm:2.2.2"
checksum: 496f7249354ab53e522510c1dc8f67a1887382187adde4dc205507d2f014836a247073b05e9d9ea51e2e9c7f71b0d2aa21730af80efa9af2d68303e5f0565c4d checksum: 496f7249354ab53e522510c1dc8f67a1887382187adde4dc205507d2f014836a247073b05e9d9ea51e2e9c7f71b0d2aa21730af80efa9af2d68303e5f0565c4d
@@ -16002,12 +16002,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"file-selector@npm:^0.1.12": "file-selector@npm:^0.6.0":
version: 0.1.19 version: 0.6.0
resolution: "file-selector@npm:0.1.19" resolution: "file-selector@npm:0.6.0"
dependencies: dependencies:
tslib: ^2.0.1 tslib: ^2.4.0
checksum: 5b105a3ede9139729ada72d6653ae3f4387a7bf2585e8700f9fa53f22457d1f88304fdde9ad7b43b694a5610d67058302257f448a75248fc2225880bca6df5df checksum: 7d051b6e5d793f3c6e2ab287ba5e7c2c6a0971bccc9d56e044c8047ba483e18f60fc0b5771c951dc707c0d15f4f36ccb4f1f1aaf385d21ec8f7700dadf8325ba
languageName: node languageName: node
linkType: hard linkType: hard
@@ -22831,6 +22831,7 @@ __metadata:
all-contributors-cli: ^6.20.0 all-contributors-cli: ^6.20.0
chai-subset: ^1.6.0 chai-subset: ^1.6.0
commitizen: ^4.2.4 commitizen: ^4.2.4
compressorjs: ^1.2.1
concurrently: ^6.2.0 concurrently: ^6.2.0
countries-list: ^2.6.1 countries-list: ^2.6.1
cross-env: ^7.0.3 cross-env: ^7.0.3
@@ -22875,7 +22876,7 @@ __metadata:
react-country-flag: ^3.1.0 react-country-flag: ^3.1.0
react-dev-utils: ^12.0.1 react-dev-utils: ^12.0.1
react-dom: 18.3.1 react-dom: 18.3.1
react-dropzone: ^10.1.10 react-dropzone: ^14.2.3
react-final-form: 6.5.3 react-final-form: 6.5.3
react-final-form-arrays: ^3.1.3 react-final-form-arrays: ^3.1.3
react-foco: ^1.3.1 react-foco: ^1.3.1
@@ -24804,16 +24805,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-dropzone@npm:^10.1.10": "react-dropzone@npm:^14.2.3":
version: 10.2.2 version: 14.2.3
resolution: "react-dropzone@npm:10.2.2" resolution: "react-dropzone@npm:14.2.3"
dependencies: dependencies:
attr-accept: ^2.0.0 attr-accept: ^2.2.2
file-selector: ^0.1.12 file-selector: ^0.6.0
prop-types: ^15.7.2 prop-types: ^15.8.1
peerDependencies: peerDependencies:
react: ">= 16.8" react: ">= 16.8 || 18.0.0"
checksum: af08b78db753dd9c277c64364c153d6cb6d563df3cc4db1731458edcc203cfa41ed3d032c5249822e5eb4d2534232e514d663a025962d08f0e6ea65550411116 checksum: 174b744d5ca898cf3d84ec1aeb6cef5211c446697e45dc8ece8287a03d291f8d07253206d5a1247ef156fd385d65e7de666d4d5c2986020b8543b8f2434e8b40
languageName: node languageName: node
linkType: hard linkType: hard