mirror of
https://github.com/darkwire/darkwire.io.git
synced 2025-07-18 02:44:01 +00:00
Fix and enhance FileTransfer component
This commit is contained in:
parent
cf696fefc7
commit
e9339dc378
@ -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
|
||||
|
||||
|
||||
|
123
client/src/components/FileTransfer/FileTransfer.js
Normal file
123
client/src/components/FileTransfer/FileTransfer.js
Normal 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
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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, '')
|
||||
}
|
||||
|
@ -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).
|
||||
|
Loading…
x
Reference in New Issue
Block a user