Skip to content

Commit

Permalink
Merge pull request #4163 from JoinColony/fix/3858-strict-min-file-size
Browse files Browse the repository at this point in the history
Fix: Ensure minimum dimension of 120x120px for file uploads
  • Loading branch information
rumzledz authored Jan 29, 2025
2 parents b11c9b3 + 9a72828 commit 623b71a
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 35 deletions.
4 changes: 2 additions & 2 deletions playwright/e2e/manage-account-avatar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test.describe('Avatar Uploader on Manage Account page', () => {
).toBeHidden();
});
// NOTE: enable this test once we have fixed issue #3858 with the avatar uploader
test.skip('rejects file smaller than 250x250px', async ({ page }) => {
test.skip('rejects file smaller than 120x120px', async ({ page }) => {
const smallImage = path.join(
__dirname,
'../fixtures/images/small-avatar-200x200.png',
Expand All @@ -94,7 +94,7 @@ test.describe('Avatar Uploader on Manage Account page', () => {

await expect(
// The error message to be confirmed
page.getByText(/Image must be at least 250x250px/i),
page.getByText(/Image must be at least 120x120px/i),
).toBeVisible({ timeout: 10000 });
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const StepCreateTokenInputs = ({
}
fileOptions={{
fileFormat: ['.PNG', '.JPG', '.SVG'],
fileDimension: '250x250px',
fileDimension: '120x120px',
fileSize: '1MB',
}}
updateFn={updateFn}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const profileFileOptions = {
fileFormat: ['.PNG', '.JPG', '.SVG'],
fileDimension: '250x250px',
fileDimension: '120x120px',
fileSize: '1MB',
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const ColonyDetailsFields: FC = () => {
name="avatar"
fileOptions={{
fileFormat: ['.PNG', '.JPG', '.SVG'],
fileDimension: '250x250px',
fileDimension: '120x120px',
fileSize: '1MB',
}}
disabled={hasNoDecisionMethods}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import { type FileReaderFile } from '~utils/fileReader/types.ts';
import {
DropzoneErrors,
getFileRejectionErrors,
validateMinimumFileDimensions,
} from '~v5/common/AvatarUploader/utils.ts';

/**
* @todo Investigate if it's sensible to unify useAvatarUploader & useChangeColonyAvatar
*/
export const useChangeColonyAvatar = () => {
const [modalValue, setModalValue] = useState<{
image: string;
Expand All @@ -27,26 +31,31 @@ export const useChangeColonyAvatar = () => {
const handleFileAccept = async (uploadedFile: FileReaderFile) => {
if (!uploadedFile) return;

setAvatarFileError(undefined);

try {
const { file } = uploadedFile;

setAvatarFileError(undefined);
setIsLoading(true);

const optimisedImage = await getOptimisedAvatarUnder300KB(
uploadedFile.file,
);
const optimisedThumbnail = await getOptimisedThumbnail(uploadedFile.file);
const optimisedImage = await getOptimisedAvatarUnder300KB(file);
const optimisedThumbnail = await getOptimisedThumbnail(file);

if (!optimisedImage) {
return;
}

await validateMinimumFileDimensions(file);

setModalValue({
image: optimisedImage,
thumbnail: optimisedThumbnail,
});
} catch (e) {
setAvatarFileError(DropzoneErrors.DEFAULT);
} catch (error) {
if (error.message === DropzoneErrors.DIMENSIONS_TOO_SMALL) {
setAvatarFileError(DropzoneErrors.DIMENSIONS_TOO_SMALL);
} else {
setAvatarFileError(DropzoneErrors.DEFAULT);
}
} finally {
setIsLoading(false);
}
Expand Down
50 changes: 36 additions & 14 deletions src/components/v5/common/AvatarUploader/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { convertBytes } from '~utils/convertBytes.ts';
import { type FileReaderFile } from '~utils/fileReader/types.ts';

import { type FileUploadOptions } from './types.ts';
import { DropzoneErrors, getFileRejectionErrors } from './utils.ts';
import {
DropzoneErrors,
getFileRejectionErrors,
validateMinimumFileDimensions,
} from './utils.ts';

export interface UseAvatarUploaderProps {
updateFn: (
Expand All @@ -20,39 +24,56 @@ export interface UseAvatarUploaderProps {
) => Promise<void>;
}

/**
* @todo Investigate if it's sensible to unify useAvatarUploader & useChangeColonyAvatar
*/
export const useAvatarUploader = ({ updateFn }: UseAvatarUploaderProps) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [uploadAvatarError, setUploadAvatarError] = useState<DropzoneErrors>();

const [showProgress, setShowProgress] = useState<boolean>();
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [file, setFileName] = useState({ fileName: '', fileSize: '' });
const [file, setFile] = useState({ fileName: '', fileSize: '' });

const handleFileUpload = async (avatarFile: FileReaderFile | null) => {
if (avatarFile) {
setUploadAvatarError(undefined);
setIsLoading(true);
const handleFileUpload = async (
avatarFileToUpload: FileReaderFile | null,
) => {
if (!avatarFileToUpload) {
return;
}

const { file: avatarFile } = avatarFileToUpload;

try {
const avatar = await getOptimisedAvatarUnder300KB(avatarFile?.file);
setFileName({
fileName: avatarFile?.file.name || '',
fileSize: convertBytes(avatarFile?.file.size, 0),
setUploadAvatarError(undefined);
setIsLoading(true);

const avatar = await getOptimisedAvatarUnder300KB(avatarFile);

setFile({
fileName: avatarFile.name || '',
fileSize: convertBytes(avatarFile.size, 0),
});

const thumbnail = await getOptimisedThumbnail(avatarFile?.file);
const thumbnail = await getOptimisedThumbnail(avatarFile);

await validateMinimumFileDimensions(avatarFile);

await updateFn(avatar, thumbnail, setUploadProgress);
} catch (e) {
if (e.message.includes('exceeded the maximum')) {
} catch (error) {
const { message: errorMessage } = error;

if (errorMessage.includes('exceeded the maximum')) {
setUploadAvatarError(DropzoneErrors.TOO_LARGE);
} else if (
e.message.includes(
// Here's where this comes from: https://github.com/nodeca/pica/blob/e4e661623a14160a824087c6a66059e3b6dba5a0/index.js#L653
errorMessage.includes(
"Pica: cannot use getImageData on canvas, make sure fingerprinting protection isn't enabled",
)
) {
setUploadAvatarError(DropzoneErrors.FINGERPRINT_ENABLED);
} else if (errorMessage === DropzoneErrors.DIMENSIONS_TOO_SMALL) {
setUploadAvatarError(DropzoneErrors.DIMENSIONS_TOO_SMALL);
} else {
setUploadAvatarError(DropzoneErrors.DEFAULT);
}
Expand All @@ -63,6 +84,7 @@ export const useAvatarUploader = ({ updateFn }: UseAvatarUploaderProps) => {
};

const handleFileRemove = async () => {
await updateFn(null, null, setUploadProgress);
await handleFileUpload(null);
setUploadAvatarError(undefined);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const ErrorContent: FC<ErrorContentProps> = ({
errorCode,
handleFileRemove,
open,
fileRejections,
processedFile,
}) => {
const { formatMessage } = useIntl();

Expand Down Expand Up @@ -46,7 +46,7 @@ const ErrorContent: FC<ErrorContentProps> = ({
</button>
</div>
<span className="break-all text-left text-sm text-gray-600">
{fileRejections}
{processedFile}
</span>
<TextButton
onClick={open}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const FileUpload: FC<FileUploadProps> = ({
getRootProps,
open,
isDragReject,
fileRejections,
processedFiles,
isDragAccept,
} = useDropzoneWithFileReader({
dropzoneOptions: {
Expand Down Expand Up @@ -70,7 +70,7 @@ const FileUpload: FC<FileUploadProps> = ({
errorCode={errorCode}
handleFileRemove={handleFileRemove}
open={open}
fileRejections={fileRejections?.[0]?.file?.name}
processedFile={processedFiles?.[0]?.name}
/>
);

Expand Down
2 changes: 1 addition & 1 deletion src/components/v5/common/AvatarUploader/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type HandleFileAccept = (file: FileReaderFile) => void;

export interface ErrorContentProps
extends Pick<FileUploadProps, 'handleFileRemove' | 'errorCode'> {
fileRejections?: string;
processedFile?: string;
open: () => void;
}

Expand Down
57 changes: 57 additions & 0 deletions src/components/v5/common/AvatarUploader/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const MSG = defineMessages({
id: `${displayName}.wrongRecipient`,
defaultMessage: 'Recipient address is incorrect, please try again',
},
fileMinDimensionsError: {
id: `${displayName}.fileMinDimensionsError`,
defaultMessage: 'Image dimensions should be at least 120x120px',
},
});

/**
Expand All @@ -54,6 +58,7 @@ export enum DropzoneErrors {
CUSTOM = 'custom-error',
DEFAULT = 'default',
FINGERPRINT_ENABLED = 'fingerprint-enabled',
DIMENSIONS_TOO_SMALL = 'dimensions-too-small',
}

/**
Expand All @@ -78,6 +83,9 @@ export const getErrorMessage = (errorCode: DropzoneErrors) => {
case DropzoneErrors.FINGERPRINT_ENABLED: {
return MSG.fingerprintError;
}
case DropzoneErrors.DIMENSIONS_TOO_SMALL: {
return MSG.fileMinDimensionsError;
}

/* Extend here with too-small and too-many as needed */

Expand All @@ -97,3 +105,52 @@ export const DEFAULT_MIME_TYPES = {
export const DEFAULT_MAX_FILE_SIZE = 1048576; // 1MB

export const DEFAULT_MAX_FILE_LIMIT = 10;

export const DEFAULT_MIN_FILE_DIMENSIONS = {
width: 120,
height: 120,
};

export const validateMinimumFileDimensions = (
file: File,
minWidth = DEFAULT_MIN_FILE_DIMENSIONS.width,
minHeight = DEFAULT_MIN_FILE_DIMENSIONS.height,
): Promise<boolean> => {
const unexpectedErrorMessage =
'An error occurred while verifying minimum dimensions';

return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();

reader.onerror = () => {
reject(new Error(unexpectedErrorMessage));
};

reader.onload = (e) => {
const result = e.target?.result;

if (!result) {
reject(new Error(unexpectedErrorMessage));

return;
}

img.src = result as string;
};

img.onload = () => {
if (img.width < minWidth || img.height < minHeight) {
reject(new Error(DropzoneErrors.DIMENSIONS_TOO_SMALL));
} else {
resolve(true);
}
};

img.onerror = () => {
reject(new Error(unexpectedErrorMessage));
};

reader.readAsDataURL(file);
});
};
9 changes: 8 additions & 1 deletion src/hooks/useDropzoneWithFileReader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import {
type DropzoneOptions,
type DropzoneProps,
Expand Down Expand Up @@ -51,7 +52,13 @@ const useDropzoneWithFileReader = ({
...restDropzoneOptions,
});

return dropzoneState;
const processedFiles = useMemo(() => {
const { fileRejections, acceptedFiles } = dropzoneState;

return { ...fileRejections.map(({ file }) => file), ...acceptedFiles };
}, [dropzoneState]);

return { ...dropzoneState, processedFiles };
};

export default useDropzoneWithFileReader;
4 changes: 2 additions & 2 deletions src/stories/common/FileUpload.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const Base: StoryObj<typeof FileUpload> = {
args: {
fileOptions: {
fileFormat: ['.csv', '.jpg', '.png'],
fileDimension: '250x250px',
fileDimension: '120x120px',
fileSize: '1MB',
},
},
Expand All @@ -36,7 +36,7 @@ export const WithSimplifiedUploader: StoryObj<typeof FileUpload> = {
isSimplified: true,
fileOptions: {
fileFormat: ['.csv'],
fileDimension: '250x250px',
fileDimension: '120x120px',
fileSize: '1MB',
},
},
Expand Down

0 comments on commit 623b71a

Please sign in to comment.