Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve attachment validation on the front-end #10118

Merged
merged 19 commits into from
Jul 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ const CONST = {
ANDROID_PACKAGE_NAME,
ANIMATED_TRANSITION: 300,

// 50 megabytes in bytes
API_MAX_ATTACHMENT_SIZE: 52428800,
API_ATTACHMENT_VALIDATIONS: {
// Same as the PHP layer allows
ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'html', 'txt', 'rtf', 'doc', 'docx', 'htm', 'tiff', 'tif', 'xml'],

// 50 megabytes in bytes
MAX_SIZE: 52428800,

// An arbitrary size, but the same minimum as in the PHP layer
MIN_SIZE: 240,
},

AVATAR_MAX_ATTACHMENT_SIZE: 6291456,
NEW_EXPENSIFY_URL: ACTIVE_EXPENSIFY_URL,
APP_DOWNLOAD_LINKS: {
Expand Down
126 changes: 88 additions & 38 deletions src/components/AttachmentModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
import {View} from 'react-native';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import _ from 'lodash';
import lodashExtend from 'lodash/extend';
import _ from 'underscore';
import CONST from '../CONST';
import Modal from './Modal';
import AttachmentView from './AttachmentView';
Expand Down Expand Up @@ -71,15 +72,17 @@ class AttachmentModal extends PureComponent {

this.state = {
isModalOpen: false,
isConfirmModalOpen: false,
isAttachmentInvalid: false,
attachmentInvalidReasonTitle: null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use empty String for attachmentInvalidReasonTitle because Confirm modal accepts string as title but we pass null which cause in this issue. Fixed it here by providing empty String.

attachmentInvalidReason: null,
file: null,
sourceURL: props.sourceURL,
modalType: CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE,
};

this.submitAndClose = this.submitAndClose.bind(this);
this.closeConfirmModal = this.closeConfirmModal.bind(this);
this.isValidSize = this.isValidSize.bind(this);
this.validateAndDisplayFileToUpload = this.validateAndDisplayFileToUpload.bind(this);
}

/**
Expand All @@ -89,22 +92,30 @@ class AttachmentModal extends PureComponent {
* @returns {String}
*/
getModalType(sourceUrl, file) {
const modalType = (sourceUrl
&& (Str.isPDF(sourceUrl) || (file && Str.isPDF(file.name || this.props.translate('attachmentView.unknownFilename')))))
return (
sourceUrl
&& (
Str.isPDF(sourceUrl)
|| (
file
&& Str.isPDF(file.name || this.props.translate('attachmentView.unknownFilename'))
)
)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just needs a few more indents and it will be ready for merge :trollface:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, the logic is already melting my brain :D

? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE
: CONST.MODAL.MODAL_TYPE.CENTERED;
return modalType;
}

/**
* Returns the filename split into fileName and fileExtension
*
* @param {String} fullFileName
* @returns {Object}
*/
splitExtensionFromFileName() {
const fullFileName = this.props.originalFileName ? this.props.originalFileName.trim() : lodashGet(this.state, 'file.name', '').trim();
const splittedFileName = fullFileName.split('.');
const fileExtension = splittedFileName.pop();
const fileName = splittedFileName.join('.');
splitExtensionFromFileName(fullFileName) {
const fileName = fullFileName.trim();
const splitFileName = fileName.split('.');
const fileExtension = splitFileName.pop();
return {fileName, fileExtension};
}

Expand All @@ -118,7 +129,7 @@ class AttachmentModal extends PureComponent {
}

if (this.props.onConfirm) {
this.props.onConfirm(_.extend(this.state.file, {source: this.state.sourceURL}));
this.props.onConfirm(lodashExtend(this.state.file, {source: this.state.sourceURL}));
}

this.setState({isModalOpen: false});
Expand All @@ -128,16 +139,70 @@ class AttachmentModal extends PureComponent {
* Close the confirm modal.
*/
closeConfirmModal() {
this.setState({isConfirmModalOpen: false});
this.setState({isAttachmentInvalid: false});
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Check if the attachment size is less than the API size limit.
* @param {Object} file
* @returns {Boolean}
*/
isValidSize(file) {
return !file || lodashGet(file, 'size', 0) < CONST.API_MAX_ATTACHMENT_SIZE;
isValidFile(file) {
if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
this.setState({
isAttachmentInvalid: true,
attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooLarge'),
attachmentInvalidReason: this.props.translate('attachmentPicker.sizeExceeded'),
});
return false;
}

if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
this.setState({
isAttachmentInvalid: true,
attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.attachmentTooSmall'),
attachmentInvalidReason: this.props.translate('attachmentPicker.sizeNotMet'),
});
return false;
}

const {fileExtension} = this.splitExtensionFromFileName(lodashGet(file, 'name', ''));
if (!_.contains(CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS, fileExtension)) {
const invalidReason = `${this.props.translate('attachmentPicker.notAllowedExtension')} ${CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS.join(', ')}`;
this.setState({
isAttachmentInvalid: true,
attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.wrongFileType'),
attachmentInvalidReason: invalidReason,
});
return false;
}

return true;
}

/**
* @param {Object} file
*/
validateAndDisplayFileToUpload(file) {
if (!file) {
return;
}
Comment on lines +186 to +188
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: I wonder when file is empty and if it's an uncovered edge case.

If it's empty because of an error, I think we should move this condition to isValidFile function and populate attachmentInvalidReasonTitle with this.props.translate('attachmentPicker.attachmentError').

Or just log something using Log.hmmm.

thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really think it's a valid edge case for file to be empty, but it protects fatal errors in the case that it is. I'd probably be more comfortable removing this condition entirely. We seem to be paranoid about this object, and I'm not 100% why.


if (!this.isValidFile(file)) {
return;
}

if (file instanceof File) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of curious whether the File is going to have a size property. Might be good to test on native and log out the value just to be sure (can help with this if you're running into issues).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can test this out on Android. I'm 100% certain File will always have size, but good to verify anyway.

const source = URL.createObjectURL(file);
const modalType = this.getModalType(source, file);
this.setState({
isModalOpen: true, sourceURL: source, file, modalType,
});
} else {
const modalType = this.getModalType(file.uri, file);
this.setState({
isModalOpen: true, sourceURL: file.uri, file, modalType,
});
}
}

render() {
Expand All @@ -149,7 +214,7 @@ class AttachmentModal extends PureComponent {
? [styles.imageModalImageCenterContainer]
: [styles.imageModalImageCenterContainer, styles.p5];

const {fileName, fileExtension} = this.splitExtensionFromFileName();
const {fileName, fileExtension} = this.splitExtensionFromFileName(this.props.originalFileName || lodashGet(this.state, 'file.name', ''));

return (
<>
Expand Down Expand Up @@ -197,34 +262,19 @@ class AttachmentModal extends PureComponent {
/>
)}
</Modal>

<ConfirmModal
title={this.props.translate('attachmentPicker.attachmentTooLarge')}
title={this.state.attachmentInvalidReasonTitle}
Comment on lines -201 to +267
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change causes a regression #22665. We just reverted back the logic i.e store translationKey in the state instead of value. Translated while passing

onConfirm={this.closeConfirmModal}
onCancel={this.closeConfirmModal}
isVisible={this.state.isConfirmModalOpen}
prompt={this.props.translate('attachmentPicker.sizeExceeded')}
isVisible={this.state.isAttachmentInvalid}
prompt={this.state.attachmentInvalidReason}
confirmText={this.props.translate('common.close')}
shouldShowCancelButton={false}
/>

{this.props.children({
displayFileInModal: ({file}) => {
if (!this.isValidSize(file)) {
this.setState({isConfirmModalOpen: true});
return;
}
if (file instanceof File) {
const source = URL.createObjectURL(file);
const modalType = this.getModalType(source, file);
this.setState({
isModalOpen: true, sourceURL: source, file, modalType,
});
} else {
const modalType = this.getModalType(file.uri, file);
this.setState({
isModalOpen: true, sourceURL: file.uri, file, modalType,
});
}
},
displayFileInModal: this.validateAndDisplayFileToUpload,
show: () => {
this.setState({isModalOpen: true});
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/ConfirmModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ConfirmContent from './ConfirmContent';

const propTypes = {
/** Title of the modal */
title: PropTypes.string.isRequired,
title: PropTypes.string,

/** A callback to call when the form has been submitted */
onConfirm: PropTypes.func.isRequired,
Expand Down Expand Up @@ -54,6 +54,7 @@ const defaultProps = {
onCancel: () => {},
shouldShowCancelButton: true,
shouldSetModalVisibility: true,
title: '',
onModalHide: () => {},
};

Expand Down
4 changes: 4 additions & 0 deletions src/languages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export default {
chooseDocument: 'Choose document',
attachmentTooLarge: 'Attachment too large',
sizeExceeded: 'Attachment size is larger than 50 MB limit.',
attachmentTooSmall: 'Attachment too small',
sizeNotMet: 'Attachment size must be greater than 240 bytes',
wrongFileType: 'Attachment is the wrong type',
notAllowedExtension: 'Attachments must be one of the following types: ',
},
composer: {
noExtentionFoundForMimeType: 'No extension found for mime type',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export default {
chooseDocument: 'Elegir documento',
attachmentTooLarge: 'Archivo adjunto demasiado grande',
sizeExceeded: 'El archivo adjunto supera el límite de 50 MB.',
attachmentTooSmall: 'Archivo adjunto demasiado pequeño',
sizeNotMet: 'El archivo adjunto debe ser mas grande que 240 bytes',
wrongFileType: 'El tipo del archivo adjunto es incorrecto',
notAllowedExtension: 'Los archivos adjuntos deben ser de uno de los siguientes tipos: ',
},
composer: {
noExtentionFoundForMimeType: 'No se encontró una extension para este tipo de contenido',
Expand Down
10 changes: 5 additions & 5 deletions src/libs/actions/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,19 +241,19 @@ function validateLogin(accountID, validateCode) {
* Checks the blockedFromConcierge object to see if it has an expiresAt key,
* and if so whether the expiresAt date of a user's ban is before right now
*
* @param {Object} blockedFromConcierge
* @param {Object} blockedFromConciergeNVP
* @returns {Boolean}
*/
function isBlockedFromConcierge(blockedFromConcierge) {
if (_.isEmpty(blockedFromConcierge)) {
function isBlockedFromConcierge(blockedFromConciergeNVP) {
if (_.isEmpty(blockedFromConciergeNVP)) {
return false;
}

if (!blockedFromConcierge.expiresAt) {
if (!blockedFromConciergeNVP.expiresAt) {
return false;
}

return moment().isBefore(moment(blockedFromConcierge.expiresAt), 'day');
return moment().isBefore(moment(blockedFromConciergeNVP.expiresAt), 'day');
}

/**
Expand Down
29 changes: 17 additions & 12 deletions src/pages/home/report/ReportActionCompose.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,14 @@ class ReportActionCompose extends React.Component {
this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this);
this.focus = this.focus.bind(this);
this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this);
this.comment = props.comment;
this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
this.onSelectionChange = this.onSelectionChange.bind(this);
this.setTextInputRef = this.setTextInputRef.bind(this);
this.getInputPlaceholder = this.getInputPlaceholder.bind(this);
this.getIOUOptions = this.getIOUOptions.bind(this);
this.addAttachment = this.addAttachment.bind(this);

this.comment = props.comment;
this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();

this.state = {
isFocused: this.shouldFocusInputOnScreenFocus,
Expand Down Expand Up @@ -447,6 +449,15 @@ class ReportActionCompose extends React.Component {
return trimmedComment;
}

/**
* @param {Object} file
*/
addAttachment(file) {
const comment = this.prepareCommentAndResetComposer();
Report.addAttachment(this.props.reportID, file, comment);
this.setTextInputShouldClear(false);
}

/**
* Add a new comment to this chat
*
Expand Down Expand Up @@ -500,11 +511,7 @@ class ReportActionCompose extends React.Component {
>
<AttachmentModal
headerTitle={this.props.translate('reportActionCompose.sendAttachment')}
onConfirm={(file) => {
const comment = this.prepareCommentAndResetComposer();
Report.addAttachment(this.props.reportID, file, comment);
this.setTextInputShouldClear(false);
}}
onConfirm={this.addAttachment}
>
{({displayFileInModal}) => (
<>
Expand Down Expand Up @@ -572,9 +579,7 @@ class ReportActionCompose extends React.Component {
text: this.props.translate('reportActionCompose.addAttachment'),
onSelected: () => {
openPicker({
onPicked: (file) => {
displayFileInModal({file});
},
onPicked: displayFileInModal,
});
},
},
Expand Down Expand Up @@ -616,15 +621,15 @@ class ReportActionCompose extends React.Component {
return;
}

displayFileInModal({file});
displayFileInModal(file);
this.setState({isDraggingOver: false});
}}
style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]}
defaultValue={this.props.comment}
maxLines={this.state.maxLines}
onFocus={() => this.setIsFocused(true)}
onBlur={() => this.setIsFocused(false)}
onPasteFile={file => displayFileInModal({file})}
onPasteFile={displayFileInModal}
shouldClear={this.state.textInputShouldClear}
onClear={() => this.setTextInputShouldClear(false)}
isDisabled={isComposeDisabled || isBlockedFromConcierge}
Expand Down
Loading