Fix and enhance FileTransfer component

This commit is contained in:
Jeremie Pardou-Piquemal 2020-05-02 10:08:00 +02:00 committed by Jérémie Pardou-Piquemal
parent cf696fefc7
commit e9339dc378
7 changed files with 233 additions and 198 deletions

View File

@ -1,5 +1,14 @@
# Api settings
TZ=UTC
REACT_APP_API_HOST=localhost
REACT_APP_API_PROTOCOL=http
REACT_APP_API_PORT=3001
REACT_APP_COMMIT_SHA=some_sha
TZ=UTC
# To display darkwire version
REACT_APP_COMMIT_SHA=some_sha
# Set max transferable file size in MB
REACT_APP_MAX_FILE_SIZE=4

View File

@ -0,0 +1,123 @@
import React from 'react'
import PropTypes from 'prop-types'
import uuid from 'uuid'
import { File } from 'react-feather'
import { sanitize } from 'utils'
import { styles } from './styles.module.scss'
const VALID_FILE_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', 'gzip', 'pdf', 'txt', 'json', 'doc', 'docx', 'csv', 'js', 'html', 'css']
const MAX_FILE_SIZE = process.env.REACT_APP_MAX_FILE_SIZE || 4
/**
* Encode the given file to binary string
* @param {File} file
*/
const encodeFile = (file) => {
return new Promise((resolve, reject) => {
const reader = new window.FileReader()
if (!file) {
reject()
return
}
reader.onload = (readerEvent) => {
resolve(window.btoa(readerEvent.target.result))
}
reader.readAsBinaryString(file)
})
}
export const FileTransfer = ({ sendEncryptedMessage }) => {
const fileInput = React.useRef(null);
const supported = React.useMemo(() =>
Boolean(window.File) && Boolean(window.FileReader) && Boolean(window.FileList) && Boolean(window.Blob) &&
Boolean(window.btoa) && Boolean(window.atob) && Boolean(window.URL),
[]
)
React.useEffect(() => {
const currentFileInput = fileInput.current
let isMounted = true;
const handleFileTransfer = async (event) => {
const file = event.target.files && event.target.files[0]
if (file) {
const fileType = file.type || 'file'
const fileName = sanitize(file.name)
const fileExtension = file.name.split('.').pop().toLowerCase()
if (VALID_FILE_TYPES.indexOf(fileExtension) <= -1) {
// eslint-disable-next-line no-alert
alert('File type not supported')
return false
}
if (file.size > MAX_FILE_SIZE * 1000000) {
// eslint-disable-next-line no-alert
alert(`Max filesize is ${MAX_FILE_SIZE}MB`)
return false
}
const fileId = uuid.v4()
const fileData = {
id: fileId,
file,
fileName,
fileType,
encodedFile: await encodeFile(file),
}
// Mounted component guard
if (!isMounted) {
return
}
fileInput.current.value = ''
sendEncryptedMessage({
type: 'SEND_FILE',
payload: {
fileName: fileData.fileName,
encodedFile: fileData.encodedFile,
fileType: fileData.fileType,
timestamp: Date.now(),
},
})
}
return false
}
currentFileInput.addEventListener('change', handleFileTransfer)
return () => {
isMounted = false
currentFileInput.removeEventListener('change', handleFileTransfer)
}
}, [sendEncryptedMessage])
if (!supported) {
return null
}
return (
<div className={`${styles} icon file-transfer btn btn-link`}>
<input placeholder="Choose a file..." type="file" name="fileUploader" id="fileInput" ref={fileInput} />
<label htmlFor="fileInput">
<File color="#fff" />
</label>
</div>
);
}
FileTransfer.propTypes = {
sendEncryptedMessage: PropTypes.func.isRequired,
}
export default FileTransfer

View File

@ -2,89 +2,103 @@ import React from 'react';
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
import FileTransfer from '.';
test('FileTransfer component is displaying', async () => {
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
// Fake date
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('2020-03-14T11:01:58.135Z').valueOf());
expect(asFragment()).toMatchSnapshot();
});
// Skipped as broken in this component version. Should be fixed later.
test.skip('FileTransfer component detect bad browser support', async () => {
describe('FileTransfer tests', () => {
const { File } = window;
// Remove one of component dependency
delete window.File;
beforeEach(() => {
// Restore original
window.File = File;
});
afterEach(() => {
// Restore original
window.File = File;
});
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
it('FileTransfer component is displaying', async () => {
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
expect(asFragment()).toMatchSnapshot();
expect(asFragment()).toMatchSnapshot();
});
window.File = File;
});
test('Try to send file', async done => {
const sendEncryptedMessage = data => {
try {
expect(data).toMatchObject({
payload: { encodedFile: 'dGV4dGZpbGU=', fileName: 'filename-png', fileType: 'text/plain' },
type: 'SEND_FILE',
});
done();
} catch (error) {
done(error);
}
};
render(<FileTransfer sendEncryptedMessage={sendEncryptedMessage} />);
const input = screen.getByPlaceholderText('Choose a file...');
const testFile = new File(['textfile'], 'filename.png', { type: 'text/plain' });
// Fire change event
fireEvent.change(input, { target: { files: [testFile] } });
});
test('Try to send no file', async () => {
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
// Fire change event
fireEvent.change(input, { target: { files: [] } });
});
test('Try to send unsupported file', async () => {
window.alert = jest.fn();
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
const testFile = new File(['textfile'], 'filename.fake', { type: 'text/plain' });
// Create thange event with fake file
const changeEvent = createEvent.change(input, { target: { files: [testFile] } });
// Fire change event
fireEvent(input, changeEvent);
expect(window.alert).toHaveBeenCalledWith('File type not supported');
});
test('Try to send too big file', async () => {
window.alert = jest.fn();
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
var fileContent = new Uint8Array(4000001);
const testFile = new File([fileContent], 'filename.png', { type: 'text/plain' });
// Fire change event
fireEvent.change(input, { target: { files: [testFile] } });
expect(window.alert).toHaveBeenCalledWith('Max filesize is 4MB');
// Skipped as broken in this component version. Should be fixed later.
it.skip('FileTransfer component detect bad browser support', () => {
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
expect(asFragment()).toMatchSnapshot();
});
it('Try to send file', async done => {
const sendEncryptedMessage = data => {
try {
expect(data).toMatchObject({
payload: {
encodedFile: 'dGV4dGZpbGU=',
fileName: 'filename.png',
fileType: 'text/plain',
timestamp: 1584183718135,
},
type: 'SEND_FILE',
});
done();
} catch (error) {
done(error);
}
};
render(<FileTransfer sendEncryptedMessage={sendEncryptedMessage} />);
const input = screen.getByPlaceholderText('Choose a file...');
const testFile = new File(['textfile'], 'filename.png', { type: 'text/plain' });
// Fire change event
fireEvent.change(input, { target: { files: [testFile] } });
});
it('Try to send no file', async () => {
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
// Fire change event
fireEvent.change(input, { target: { files: [] } });
});
it('Try to send unsupported file', async () => {
window.alert = jest.fn();
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
const testFile = new File(['textfile'], 'filename.fake', { type: 'text/plain' });
// Create thange event with fake file
const changeEvent = createEvent.change(input, { target: { files: [testFile] } });
// Fire change event
fireEvent(input, changeEvent);
expect(window.alert).toHaveBeenCalledWith('File type not supported');
});
it('Try to send too big file', async () => {
window.alert = jest.fn();
render(<FileTransfer sendEncryptedMessage={() => {}} />);
const input = screen.getByPlaceholderText('Choose a file...');
var fileContent = new Uint8Array(4000001);
const testFile = new File([fileContent], 'filename.png', { type: 'text/plain' });
// Fire change event
fireEvent.change(input, { target: { files: [testFile] } });
expect(window.alert).toHaveBeenCalledWith('Max filesize is 4MB');
});
});

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileTransfer component is displaying 1`] = `
exports[`FileTransfer tests FileTransfer component is displaying 1`] = `
<DocumentFragment>
<div
class="styles icon file-transfer btn btn-link"

View File

@ -1,116 +1,3 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import uuid from 'uuid'
import { File } from 'react-feather'
import { sanitize } from 'utils'
import {styles} from './styles.module.scss'
import FileTransfer from './FileTransfer';
const VALID_FILE_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', 'gzip', 'pdf', 'txt', 'json', 'doc', 'docx', 'csv', 'js', 'html', 'css']
export default class FileTransfer extends Component {
constructor() {
super()
this.state = {
supported: true,
localFileQueue: [],
}
}
componentWillMount() {
if (!window.File && !window.FileReader && !window.FileList && !window.Blob && !window.btoa && !window.atob && !window.Blob && !window.URL) {
this.setState({
supported: false,
})
}
}
componentDidMount() {
this._fileInput.addEventListener('change', this.confirmFileTransfer.bind(this))
}
async encodeFile(file) {
return new Promise((resolve, reject) => {
const reader = new window.FileReader()
if (!file) {
reject()
return
}
reader.onload = (readerEvent) => {
resolve(window.btoa(readerEvent.target.result))
this._fileInput.value = ''
}
reader.readAsBinaryString(file)
})
}
async confirmFileTransfer(event) {
const file = event.target.files && event.target.files[0]
if (file) {
const fileType = file.type || 'file'
const fileName = sanitize(file.name)
const { localFileQueue } = this.state
const fileExtension = file.name.split('.').pop().toLowerCase()
if (VALID_FILE_TYPES.indexOf(fileExtension) <= -1) {
// eslint-disable-next-line no-alert
alert('File type not supported')
return false
}
if (file.size > 4 * 1000000) {
// eslint-disable-next-line no-alert
alert('Max filesize is 4MB')
return false
}
const fileId = uuid.v4()
const fileData = {
id: fileId,
file,
fileName,
fileType,
encodedFile: await this.encodeFile(file),
}
localFileQueue.push(fileData)
this.setState({
localFileQueue,
}, async () => {
this.props.sendEncryptedMessage({
type: 'SEND_FILE',
payload: {
fileName: fileData.fileName,
encodedFile: fileData.encodedFile,
fileType: fileData.fileType,
timestamp: Date.now(),
},
})
})
}
return false
}
render() {
if (!this.state.supported) {
return null
}
return (
<div className={`${styles} icon file-transfer btn btn-link`}>
<input placeholder="Choose a file..." type="file" name="fileUploader" id="fileInput" ref={c => this._fileInput = c} />
<label htmlFor="fileInput">
<File color="#fff" />
</label>
</div>
)
}
}
FileTransfer.propTypes = {
sendEncryptedMessage: PropTypes.func.isRequired,
}
export default FileTransfer;

View File

@ -1,4 +1,4 @@
/* eslint-disable import/prefer-default-export */
export function sanitize(str) {
return str.replace(/[^A-Za-z0-9]/g, '-').replace(/[<>]/ig, '')
return str.replace(/[^A-Za-z0-9._]/g, '-').replace(/[<>]/ig, '')
}

View File

@ -87,12 +87,14 @@ Darkwire does not provide any guarantee that the person you're communicating wit
## File Transfer
Darkwire encodes documents (up to 1MB) into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are.
Darkwire encodes documents into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are.
1. When a file is "uploaded", the document is encoded on the client and the server recieves the encrypted base64 string.
2. The server sends the encrypted base64 string to clients in the same chat room.
3. Clients recieving the encrypted base64 string then decrypts and decodes the base64 string using [atob](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/atob).
The default transferable file size limit is 4MB, but can be changed in `.env` file with the `REACT_APP_MAX_FILE_SIZE` variable.
## Sockets & Server
Darkwire uses [socket.io](http://socket.io) to transmit encrypted information using secure [WebSockets](https://en.wikipedia.org/wiki/WebSocket) (WSS).