mirror of
https://github.com/darkwire/darkwire.io.git
synced 2025-07-18 18:54:52 +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_HOST=localhost
|
||||||
REACT_APP_API_PROTOCOL=http
|
REACT_APP_API_PROTOCOL=http
|
||||||
REACT_APP_API_PORT=3001
|
REACT_APP_API_PORT=3001
|
||||||
REACT_APP_COMMIT_SHA=some_sha
|
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,31 +2,44 @@ import React from 'react';
|
|||||||
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
|
||||||
import FileTransfer from '.';
|
import FileTransfer from '.';
|
||||||
|
|
||||||
test('FileTransfer component is displaying', async () => {
|
// Fake date
|
||||||
|
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('2020-03-14T11:01:58.135Z').valueOf());
|
||||||
|
|
||||||
|
describe('FileTransfer tests', () => {
|
||||||
|
const { File } = window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Restore original
|
||||||
|
window.File = File;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original
|
||||||
|
window.File = File;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FileTransfer component is displaying', async () => {
|
||||||
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skipped as broken in this component version. Should be fixed later.
|
// Skipped as broken in this component version. Should be fixed later.
|
||||||
test.skip('FileTransfer component detect bad browser support', async () => {
|
it.skip('FileTransfer component detect bad browser support', () => {
|
||||||
const { File } = window;
|
|
||||||
|
|
||||||
// Remove one of component dependency
|
|
||||||
delete window.File;
|
|
||||||
|
|
||||||
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
window.File = File;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Try to send file', async done => {
|
it('Try to send file', async done => {
|
||||||
const sendEncryptedMessage = data => {
|
const sendEncryptedMessage = data => {
|
||||||
try {
|
try {
|
||||||
expect(data).toMatchObject({
|
expect(data).toMatchObject({
|
||||||
payload: { encodedFile: 'dGV4dGZpbGU=', fileName: 'filename-png', fileType: 'text/plain' },
|
payload: {
|
||||||
|
encodedFile: 'dGV4dGZpbGU=',
|
||||||
|
fileName: 'filename.png',
|
||||||
|
fileType: 'text/plain',
|
||||||
|
timestamp: 1584183718135,
|
||||||
|
},
|
||||||
type: 'SEND_FILE',
|
type: 'SEND_FILE',
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
@ -45,7 +58,7 @@ test('Try to send file', async done => {
|
|||||||
fireEvent.change(input, { target: { files: [testFile] } });
|
fireEvent.change(input, { target: { files: [testFile] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Try to send no file', async () => {
|
it('Try to send no file', async () => {
|
||||||
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Choose a file...');
|
const input = screen.getByPlaceholderText('Choose a file...');
|
||||||
@ -54,7 +67,7 @@ test('Try to send no file', async () => {
|
|||||||
fireEvent.change(input, { target: { files: [] } });
|
fireEvent.change(input, { target: { files: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Try to send unsupported file', async () => {
|
it('Try to send unsupported file', async () => {
|
||||||
window.alert = jest.fn();
|
window.alert = jest.fn();
|
||||||
|
|
||||||
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
@ -72,7 +85,7 @@ test('Try to send unsupported file', async () => {
|
|||||||
expect(window.alert).toHaveBeenCalledWith('File type not supported');
|
expect(window.alert).toHaveBeenCalledWith('File type not supported');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Try to send too big file', async () => {
|
it('Try to send too big file', async () => {
|
||||||
window.alert = jest.fn();
|
window.alert = jest.fn();
|
||||||
|
|
||||||
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
@ -88,3 +101,4 @@ test('Try to send too big file', async () => {
|
|||||||
|
|
||||||
expect(window.alert).toHaveBeenCalledWith('Max filesize is 4MB');
|
expect(window.alert).toHaveBeenCalledWith('Max filesize is 4MB');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`FileTransfer component is displaying 1`] = `
|
exports[`FileTransfer tests FileTransfer component is displaying 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
class="styles icon file-transfer btn btn-link"
|
class="styles icon file-transfer btn btn-link"
|
||||||
|
@ -1,116 +1,3 @@
|
|||||||
import React, { Component } from 'react'
|
import FileTransfer from './FileTransfer';
|
||||||
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']
|
export default FileTransfer;
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
/* eslint-disable import/prefer-default-export */
|
||||||
export function sanitize(str) {
|
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
|
## 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.
|
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.
|
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).
|
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
|
## Sockets & Server
|
||||||
|
|
||||||
Darkwire uses [socket.io](http://socket.io) to transmit encrypted information using secure [WebSockets](https://en.wikipedia.org/wiki/WebSocket) (WSS).
|
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