├── .eslintrc.json ├── .gitignore ├── README.md ├── demo ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── styles.css └── src │ ├── App.jsx │ ├── App.test.jsx │ ├── confetti │ └── Confetti.jsx │ ├── filePicker │ ├── FilePicker.jsx │ ├── FilePicker.test.jsx │ └── __mocks__ │ │ ├── .FileReader.js.swp │ │ └── FileReader.js │ ├── index.jsx │ ├── notificationSystem │ ├── NotificationSystem.jsx │ └── NotificationSystem.test.jsx │ ├── presignedUrlInput │ ├── PresignedUrlInput.jsx │ └── PresignedUrlInput.test.jsx │ ├── serviceWorker.js │ └── uploadFileButton │ ├── UploadFileButton.jsx │ └── UploadFileButton.test.jsx ├── dist ├── urlExtractor │ ├── UrlExtractor.js │ └── UrlExtractor.test.js ├── useUploadS3WithPresignedUrl.js └── useUploadS3WithPresignedUrl.test.js ├── docs ├── airbnb.png ├── react-s3-v2.gif └── react-s3.gif ├── index.js └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "airbnb" 8 | ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 2018, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "react" 22 | ], 23 | "rules": { 24 | } 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | *.swp 26 | *.swo 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Welcome to react-s3 👋

2 |

3 | React S3 4 |

5 |

6 | 7 |

8 | 9 | > React custom hooks for uploading files to a s3 bucket with progress showing abilities 10 | 11 | ## Install 12 | 13 | ```sh 14 | npm i react-use-s3 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```javascript 20 | 21 | import useUploadS3WithPresignedUrl from 'react-use-s3'; //ES6 22 | 23 | const url = 'your presigned url here'; 24 | const file = { name: '', type: '', data: '' } 25 | const [progress, setProgress] = useState('In Progress'); //useState hook to indicate progress of file while uploading 26 | const [response, setResponse] = useState({ status: 0, responseText: '' }); //useState hook to indicate upload response when 27 | //request is done whether is successful or not 28 | 29 | //Upon successful upload request, setResponse will set the response as: 30 | { 31 | status: 200, 32 | responseText: 'https://{bucket}.{region}.amazonaws.com/{fileName}.{fileExtension}' 33 | } 34 | 35 | //Upon unsucessful upload request, setResponse will set the response as: 36 | { 37 | status: 403, 38 | responseText: 'Upload server error, please check your presigned url' 39 | } 40 | 41 | const handleClick = useUploadS3WithPresignedUrl({ 42 | url, 43 | file, 44 | setResponse, 45 | setProgress, 46 | }); 47 | 48 | 49 | 50 | ``` 51 | ## Demo 52 | 53 | This demo implies that you have the following configuration on your s3 bucket before upload: 54 | 55 | Bucket Policy: 56 | 57 | ```sh 58 | { 59 | "Version": "2012-10-17", 60 | "Id": "Policy1561076265996", 61 | "Statement": [ 62 | { 63 | "Sid": "Stmt1561076264365", 64 | "Effect": "Allow", 65 | "Principal": "*", 66 | "Action": "s3:PutObject", 67 | "Resource": "arn:aws:s3:::[your bucket name here]/*" 68 | } 69 | ] 70 | } 71 | ``` 72 | 73 | CORS: 74 | 75 | ```sh 76 | 77 | 78 | 79 | * 80 | PUT 81 | POST 82 | 3000 83 | x-amz-server-side-encryption 84 | x-amz-request-id 85 | x-amz-id-2 86 | ETag #This is needed in order to upload heavy files 87 | * 88 | 89 | 90 | ``` 91 | 92 | In order to install and run the demo app locally you can do the following: 93 | 94 | ```sh 95 | npm install 96 | ``` 97 | 98 | ```sh 99 | npm run test 100 | ``` 101 | 102 | ```sh 103 | npm run start 104 | ``` 105 | 106 | So why the Dockerfile? It was running on my machine said every dev. If you wish to run the demo app 107 | on a container, all you have to do is the following: 108 | 109 | ```sh 110 | docker build -t react-s3-demo . 111 | docker run -it -p 8080:80 react-s3-demo 112 | ``` 113 | At this point, all you have to do is go to localhost:8080 in your browser and there you go. You can 114 | upload your files to s3 your bucket while having the ability to show progress 115 | 116 | ## Code Style 117 | ``` 118 | eslint-config-standard 119 | ``` 120 | 121 | [![js-standard-style](https://cdn.rawgit.com/standard/standard/master/badge.svg)](http://standardjs.com) 122 | -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "airbnb", 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 2018, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react" 20 | ], 21 | "rules": { 22 | } 23 | } -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | *.swo 4 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine AS builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN npm run build 5 | 6 | FROM node:12-alpine 7 | RUN npm install -g serve 8 | WORKDIR /app 9 | COPY --from=builder /app/build . 10 | EXPOSE 8080 11 | CMD ["serve", "-p", "80", "-s", "."] 12 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-s3", 3 | "version": "2.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "prop-types": "^15.7.2", 7 | "react": "^16.8.6", 8 | "react-confetti": "^3.1.1", 9 | "react-dom": "^16.8.6", 10 | "react-dropzone": "^10.1.5", 11 | "react-notification-system": "^0.2.17", 12 | "react-scripts": "3.0.1", 13 | "react-use": "^10.3.0", 14 | "react-use-s3": "^2.0.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --silent", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "@testing-library/dom": "^5.5.1", 39 | "@testing-library/jest-dom": "^4.0.0", 40 | "@testing-library/react": "^8.0.4", 41 | "eslint": "^5.16.0", 42 | "eslint-config-airbnb": "^17.1.1", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-jsx-a11y": "^6.2.3", 45 | "eslint-plugin-react": "^7.14.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastianserrano/react-use-s3/2fbb7070086f80d9bd527f47842a3627d83faaa0/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | React S3 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo/public/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: darkgray; 3 | } 4 | 5 | #main-container { 6 | flex: 1; 7 | display: flex; /* establish flex container */ 8 | flex-direction: column; /* make main-axis vertical */ 9 | justify-content: center; /* align items vertically, in this case */ 10 | min-height: 100vh; 11 | } 12 | 13 | .inherit-height { 14 | height: inherit; 15 | } 16 | 17 | #title { 18 | width: 100%; 19 | margin-top: 50px; 20 | margin-bottom: 40px; 21 | } 22 | 23 | #file-picker { 24 | height: 20em; 25 | background-color: azure; 26 | border-radius: 5px; 27 | } 28 | 29 | #file-picker p { 30 | font-size: 1.25em; 31 | } 32 | 33 | #file-picker-title { 34 | width: 100%; 35 | } 36 | 37 | #upload-button { 38 | width: 50%; 39 | margin-top: 50px; 40 | margin-bottom: 50px; 41 | } 42 | 43 | #presigned-url { 44 | margin-bottom: 30px; 45 | } 46 | -------------------------------------------------------------------------------- /demo/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PresignedUrlInput from './presignedUrlInput/PresignedUrlInput'; 3 | import FilePicker from './filePicker/FilePicker'; 4 | import UploadFileButton from './uploadFileButton/UploadFileButton'; 5 | import NotificationSystem from './notificationSystem/NotificationSystem'; 6 | import Confetti from './confetti/Confetti'; 7 | 8 | function App() { 9 | const [file, setFile] = useState({ name: '', type: '', data: '' }); 10 | const [presignedUrl, setPresignedUrl] = useState(''); 11 | const [response, setResponse] = useState({ status: 0, responseText: '' }); 12 | const [progress, setProgress] = useState('In Progress'); 13 | 14 | return ( 15 |
16 | 17 | {response.status === 200 ? : null} 18 |
19 |

React S3

20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 | 36 |
37 |
38 |
39 | ); 40 | } 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /demo/src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | import App from './App'; 5 | 6 | afterEach(cleanup); 7 | 8 | describe('React Use S3 Demo', () => { 9 | it('notification system should be in the document', () => { 10 | const { container } = render(); 11 | const notificationSystem = container.querySelector('.notifications-wrapper'); 12 | 13 | expect(notificationSystem).toBeInTheDocument(); 14 | }); 15 | 16 | it('should render "React S3" as title', () => { 17 | const { getByText } = render(); 18 | const title = getByText('React S3'); 19 | 20 | expect(title).toBeInTheDocument(); 21 | }); 22 | 23 | it('should render file picker', () => { 24 | const { getByTestId } = render(); 25 | const title = getByTestId('file-picker'); 26 | 27 | expect(title).toBeInTheDocument(); 28 | }); 29 | 30 | it('should render upload file button', () => { 31 | const { getByText } = render(); 32 | const title = getByText('Upload File'); 33 | 34 | expect(title).toBeInTheDocument(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /demo/src/confetti/Confetti.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useWindowSize from 'react-use/lib/useWindowSize'; 3 | import Confetti from 'react-confetti'; 4 | 5 | export default () => { 6 | const { width, height } = useWindowSize(); 7 | return ( 8 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /demo/src/filePicker/FilePicker.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useDropzone } from 'react-dropzone'; 4 | 5 | function FilePicker(props) { 6 | const [temporaryFile, setTemporaryFile] = useState({ name: '', type: '' }); 7 | const { setFile } = props; 8 | 9 | const onDrop = useCallback((acceptedFiles) => { 10 | const reader = new FileReader(); 11 | 12 | reader.onload = () => { 13 | const binaryFile = reader.result; 14 | 15 | setFile({ 16 | name: temporaryFile.name, 17 | type: temporaryFile.type, 18 | data: binaryFile, 19 | }); 20 | }; 21 | 22 | acceptedFiles.forEach((file) => { 23 | setTemporaryFile({ 24 | name: file.name, 25 | type: file.type, 26 | }); 27 | return reader.readAsBinaryString(file); 28 | }); 29 | }, [setFile, temporaryFile]); 30 | const { getRootProps, getInputProps } = useDropzone({ onDrop }); 31 | 32 | return ( 33 |
34 |
35 | 36 |
37 |

38 | {temporaryFile.name !== '' ? temporaryFile.name : 'Drag n drop a file here, or click to select file' } 39 |

40 |
41 |
42 |
43 | ); 44 | } 45 | 46 | FilePicker.propTypes = { 47 | fileName: PropTypes.string.isRequired, 48 | setFile: PropTypes.func.isRequired, 49 | setFileName: PropTypes.func.isRequired, 50 | }; 51 | 52 | export default FilePicker; 53 | -------------------------------------------------------------------------------- /demo/src/filePicker/FilePicker.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | render, 4 | cleanup, 5 | fireEvent, 6 | act, 7 | } from '@testing-library/react'; 8 | import '@testing-library/jest-dom'; 9 | import FilePicker from './FilePicker'; 10 | 11 | afterEach(cleanup); 12 | 13 | describe('React Use S3 Demo', () => { 14 | it('should render file picker', () => { 15 | const mockedFileName = ''; 16 | const mockedSetFile = jest.fn(); 17 | const mockedSetFileName = jest.fn(); 18 | 19 | const { getByTestId } = render( 20 | , 25 | ); 26 | 27 | const filePicker = getByTestId('file-picker-input'); 28 | 29 | act(() => { 30 | fireEvent.drop(filePicker, { 31 | target: { 32 | files: [new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' })], 33 | }, 34 | }); 35 | }); 36 | 37 | //expect(mockedSetFileName).toHaveBeenCalled(); 38 | //expect(mockedSetFile).toHaveBeenCalled(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /demo/src/filePicker/__mocks__/.FileReader.js.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastianserrano/react-use-s3/2fbb7070086f80d9bd527f47842a3627d83faaa0/demo/src/filePicker/__mocks__/.FileReader.js.swp -------------------------------------------------------------------------------- /demo/src/filePicker/__mocks__/FileReader.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | class FileReader extends EventEmitter { 4 | constructor() { 5 | super(); 6 | console.log('initializing file reader niggas'); 7 | } 8 | 9 | readAsBinaryString() { 10 | } 11 | } 12 | 13 | export default FileReader; 14 | -------------------------------------------------------------------------------- /demo/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: https://bit.ly/CRA-PWA 11 | serviceWorker.unregister(); 12 | -------------------------------------------------------------------------------- /demo/src/notificationSystem/NotificationSystem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactNotificationSystem from 'react-notification-system'; 4 | 5 | class NotificationSystem extends React.Component { 6 | constructor() { 7 | super(); 8 | this.notificationSystem = React.createRef(); 9 | } 10 | 11 | componentDidUpdate() { 12 | const { response } = this.props; 13 | const { status, responseText } = response; 14 | 15 | const notificationSystem = this.notificationSystem.current; 16 | 17 | if (status === 200) { 18 | notificationSystem.addNotification({ 19 | title: 'File location', 20 | message: responseText, 21 | level: 'info', 22 | position: 'tc', 23 | }); 24 | } else if (status !== 0) { 25 | notificationSystem.addNotification({ 26 | title: 'Something went wrong', 27 | message: responseText, 28 | level: 'error', 29 | position: 'tc', 30 | }); 31 | } 32 | } 33 | 34 | render() { 35 | return ( 36 | 37 | ); 38 | } 39 | } 40 | 41 | NotificationSystem.propTypes = { 42 | response: PropTypes.object.isRequired, 43 | }; 44 | 45 | export default NotificationSystem; 46 | -------------------------------------------------------------------------------- /demo/src/notificationSystem/NotificationSystem.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | import NotificationSystem from './NotificationSystem'; 5 | 6 | afterEach(cleanup); 7 | 8 | describe('Notification System', () => { 9 | it('notification system should render a level \'info\' notification upon successful upload', () => { 10 | const response = { 11 | status: 200, 12 | responseText: 'https://lambda-s3-0.s3.us-east-2.amazonaws.com/pic.jpeg', 13 | }; 14 | const { rerender, container } = render(); 15 | 16 | rerender(); 17 | 18 | const notificationTitle = container.querySelector('.notification-title'); 19 | const notificationMessage = container.querySelector('.notification-message'); 20 | 21 | expect(notificationTitle).toHaveTextContent('File location'); 22 | expect(notificationMessage).toHaveTextContent(response.responseText); 23 | }); 24 | 25 | it('notification system should render a level \'error\' notification bad request upload', () => { 26 | const response = { 27 | status: 403, 28 | responseText: 'Upload server error, please check your presigned url', 29 | }; 30 | const { rerender, container } = render(); 31 | 32 | rerender(); 33 | 34 | const notificationTitle = container.querySelector('.notification-title'); 35 | const notificationMessage = container.querySelector('.notification-message'); 36 | 37 | expect(notificationTitle).toHaveTextContent('Something went wrong'); 38 | expect(notificationMessage).toHaveTextContent(response.responseText); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /demo/src/presignedUrlInput/PresignedUrlInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | function PresignedUrlInput(props) { 5 | const { setPresignedUrl } = props; 6 | 7 | const handleChange = (event) => { 8 | const url = event.target.value; 9 | 10 | setPresignedUrl(url); 11 | }; 12 | 13 | return ( 14 | handleChange(event)} 17 | id="presigned-url" 18 | className="form-control text-center" 19 | placeholder="Presigned Url" 20 | aria-label="Username" 21 | aria-describedby="basic-addon1" 22 | /> 23 | ); 24 | } 25 | 26 | PresignedUrlInput.propTypes = { 27 | setPresignedUrl: PropTypes.func.isRequired, 28 | }; 29 | 30 | export default PresignedUrlInput; 31 | -------------------------------------------------------------------------------- /demo/src/presignedUrlInput/PresignedUrlInput.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | render, 4 | cleanup, 5 | fireEvent, 6 | act, 7 | } from '@testing-library/react'; 8 | import '@testing-library/jest-dom'; 9 | import PresignedUrlInput from './PresignedUrlInput'; 10 | 11 | afterEach(cleanup); 12 | 13 | describe('Presigned url input', () => { 14 | it('should display presigned url upon user input change', () => { 15 | const mockedSetPresignedUrl = jest.fn(); 16 | const mockedPresignedUrl = 'mockedPresignedUrl'; 17 | const { getByPlaceholderText } = render(); 18 | 19 | const presignedUrlInput = getByPlaceholderText('Presigned Url'); 20 | 21 | fireEvent.change(presignedUrlInput, { target: { value: mockedPresignedUrl } }); 22 | 23 | expect(presignedUrlInput.value).toEqual(mockedPresignedUrl); 24 | }); 25 | 26 | it('should fire setPresignedUrl upon user input change', () => { 27 | const mockedSetPresignedUrl = jest.fn(); 28 | const mockedPresignedUrl = 'mockedPresignedUrl'; 29 | const { getByPlaceholderText } = render(); 30 | 31 | const presignedUrlInput = getByPlaceholderText('Presigned Url'); 32 | 33 | fireEvent.change(presignedUrlInput, { target: { value: mockedPresignedUrl } }); 34 | 35 | expect(mockedSetPresignedUrl).toHaveBeenCalled(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /demo/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /demo/src/uploadFileButton/UploadFileButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import useUploadS3WithPresignedUrl from 'react-use-s3'; 4 | 5 | function UploadFileButton(props) { 6 | const { 7 | url, 8 | progress, 9 | file, 10 | setResponse, 11 | setProgress, 12 | } = props; 13 | 14 | const handleClick = useUploadS3WithPresignedUrl({ 15 | url, 16 | file, 17 | setResponse, 18 | setProgress, 19 | }); 20 | 21 | return ( 22 |
23 |
24 | 33 |
34 |
35 | ); 36 | } 37 | 38 | UploadFileButton.propTypes = { 39 | url: PropTypes.string.isRequired, 40 | progress: PropTypes.string.isRequired, 41 | file: PropTypes.object.isRequired, 42 | setResponse: PropTypes.func.isRequired, 43 | setProgress: PropTypes.func.isRequired, 44 | }; 45 | 46 | export default UploadFileButton; 47 | -------------------------------------------------------------------------------- /demo/src/uploadFileButton/UploadFileButton.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup, render } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | import UploadFileButton from './UploadFileButton'; 5 | 6 | afterEach(cleanup); 7 | 8 | describe('Upload File Button', () => { 9 | it('should render disabled when presigned url input is empty', () => { 10 | const url = ''; 11 | const progress = 'In Progress'; 12 | const file = {}; 13 | const setResponse = jest.fn(); 14 | const setProgress = jest.fn(); 15 | 16 | const { getByText } = render( 17 | , 24 | ); 25 | const result = getByText('Upload File'); 26 | expect(result).toBeDisabled(); 27 | }); 28 | 29 | it('should render enabled when presigned url input is not empty', () => { 30 | const url = 'mockedPresignedUrl'; 31 | const progress = 'In Progress'; 32 | const file = {}; 33 | const setResponse = jest.fn(); 34 | const setProgress = jest.fn(); 35 | 36 | const { getByText } = render( 37 | , 44 | ); 45 | const result = getByText('Upload File'); 46 | expect(result).toBeEnabled(); 47 | }); 48 | 49 | it('should render with text \'Upload File\' before upload', () => { 50 | const url = 'mockedPresignedUrl'; 51 | const progress = 'In Progress'; 52 | const file = {}; 53 | const setResponse = jest.fn(); 54 | const setProgress = jest.fn(); 55 | 56 | const { getByText } = render( 57 | , 64 | ); 65 | const result = getByText('Upload File'); 66 | expect(result).toHaveTextContent('Upload File'); 67 | }); 68 | 69 | it('should render with text progress while uploading', () => { 70 | const url = 'mockedPresignedUrl'; 71 | const progress = '1'; 72 | const file = {}; 73 | const setResponse = jest.fn(); 74 | const setProgress = jest.fn(); 75 | 76 | const { getByText } = render( 77 | , 84 | ); 85 | const result = getByText(progress); 86 | expect(result).toHaveTextContent(progress); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /dist/urlExtractor/UrlExtractor.js: -------------------------------------------------------------------------------- 1 | class UrlExtractor { 2 | constructor() { 3 | this.regex = new RegExp(/(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,}).*?(?=\?)/, 'gi'); 4 | } 5 | 6 | extractFileLocationFromPresignedUrl(presignedUrl) { 7 | if (this.regexMatchesPresignedUrl(presignedUrl)) { 8 | const fileLocation = presignedUrl.match(this.regex)[0]; 9 | 10 | return fileLocation; 11 | } 12 | return 'Could not extract file location from presigned url'; 13 | } 14 | 15 | regexMatchesPresignedUrl(url) { 16 | return this.regex.test(url); 17 | } 18 | } 19 | 20 | module.exports = UrlExtractor; 21 | -------------------------------------------------------------------------------- /dist/urlExtractor/UrlExtractor.test.js: -------------------------------------------------------------------------------- 1 | const UrlExtractor = require('./UrlExtractor.js'); 2 | 3 | describe('Url Extractor', () => { 4 | it('should extract file location from presigned url', () => { 5 | const urlExtractor = new UrlExtractor(); 6 | const presignedUrl = 'https://lambda-s3-0.s3.us-east-2.amazonaws.com/pic.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQV5F6T53542MBDN3%2F20190731%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20190731T002736Z&X-Amz-Expires=300&X-Amz-Signature=261333f9e5857cab2de2084c54b3af284dad7399e5c4615eab90a777aced8f8e&X-Amz-SignedHeaders=host'; 7 | 8 | const fileLocation = urlExtractor.extractFileLocationFromPresignedUrl(presignedUrl); 9 | 10 | expect(fileLocation).toEqual('https://lambda-s3-0.s3.us-east-2.amazonaws.com/pic.jpeg'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /dist/useUploadS3WithPresignedUrl.js: -------------------------------------------------------------------------------- 1 | const { useCallback } = require('react'); 2 | const UrlExtractor = require('./urlExtractor/UrlExtractor.js'); 3 | 4 | function useUploadS3WithPresignedUrl(props) { 5 | const { 6 | url, 7 | file, 8 | setResponse, 9 | setProgress, 10 | } = props; 11 | 12 | const callback = useCallback(() => { 13 | const request = new XMLHttpRequest(); 14 | request.open('PUT', url, true); 15 | request.setRequestHeader('Content-Type', file.type); 16 | request.onerror = () => { 17 | setResponse({ 18 | status: 403, 19 | responseText: 'Upload server error, please try again later', 20 | }); 21 | }; 22 | request.onreadystatechange = () => { 23 | if (request.readyState === 4 && request.status === 200) { 24 | setResponse({ 25 | status: 200, 26 | responseText: new UrlExtractor().extractFileLocationFromPresignedUrl(url), 27 | }); 28 | } else if (request.readyState === 4 && request.status !== 200) { 29 | setResponse({ 30 | status: 403, 31 | responseText: 'Upload server error, please check your presigned url', 32 | }); 33 | } 34 | }; 35 | request.upload.onprogress = (event) => { 36 | if (event.lengthComputable) { 37 | const progress = Math.round((event.loaded / event.total) * 100); 38 | setProgress(progress); 39 | } 40 | }; 41 | request.send(file.data); 42 | }, [file, url, setProgress, setResponse]); 43 | 44 | return callback; 45 | } 46 | 47 | module.exports = useUploadS3WithPresignedUrl; 48 | -------------------------------------------------------------------------------- /dist/useUploadS3WithPresignedUrl.test.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { renderHook, act } = require('@testing-library/react-hooks'); 3 | const useUploadS3WithPresignedUrl = require('./useUploadS3WithPresignedUrl'); 4 | 5 | const props = { 6 | url: 'https://lambda-s3-0.s3.us-east-2.amazonaws.com/pic.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQV5F6T53542MBDN3%2F20190729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20190729T195346Z&X-Amz-Expires=300&X-Amz-Signature=17e6e3665d476b9648bd4e9e6b3f3153c4c4f7fef947b127e64e464698d1ef14&X-Amz-SignedHeaders=host', 7 | file: { 8 | name: 'mockedFileName', 9 | type: 'image/jpeg', 10 | data: '', 11 | }, 12 | setResponse: jest.fn(), 13 | setProgress: jest.fn(), 14 | }; 15 | 16 | const requests = []; 17 | 18 | beforeAll(() => { 19 | const mockedXMLHttpRequest = sinon.useFakeXMLHttpRequest(); 20 | mockedXMLHttpRequest.onCreate = (request) => { 21 | requests.push(request); 22 | }; 23 | global.XMLHttpRequest = mockedXMLHttpRequest; 24 | }); 25 | 26 | describe('Use Upload S3 Presigned Url', () => { 27 | it('should be called with exact presigned url', () => { 28 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 29 | act(() => { 30 | result.current(); 31 | }); 32 | 33 | expect(requests[0].url).toEqual(props.url); 34 | }); 35 | 36 | it('should be called with PUT method', () => { 37 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 38 | act(() => { 39 | result.current(); 40 | }); 41 | 42 | expect(requests[1].method).toEqual('PUT'); 43 | }); 44 | 45 | it('should be called with correct request headers', () => { 46 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 47 | act(() => { 48 | result.current(); 49 | }); 50 | 51 | expect(requests[2].requestHeaders).toEqual( 52 | expect.objectContaining({ 53 | 'Content-Type': 'image/jpeg;charset=utf-8', 54 | }), 55 | ); 56 | }); 57 | 58 | it('should be called asynchronously', () => { 59 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 60 | act(() => { 61 | result.current(); 62 | }); 63 | 64 | expect(requests[3].async).toEqual(true); 65 | }); 66 | 67 | it('onerror should set a reponse with status 403', () => { 68 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 69 | act(() => { 70 | result.current(); 71 | }); 72 | 73 | requests[4].error(); 74 | 75 | expect(props.setResponse.mock.calls[0][0]).toEqual( 76 | expect.objectContaining({ 77 | status: 403, 78 | responseText: 'Upload server error, please check your presigned url', 79 | }), 80 | ); 81 | }); 82 | 83 | it('on success should set a response with status 200 and file location url', () => { 84 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 85 | act(() => { 86 | result.current(); 87 | }); 88 | 89 | requests[5].respond(200, { 90 | etag: '7d71fc9df2f745537fb877aad019a988', 91 | 'x-amz-id-2': 'XWnIXBx3TJM8fV4AiFddnBGYmdCWmxarSwzAWDakjzYiPcvppZVsvcRvrXCPXOI6cB9MVxllRgs=', 92 | 'x-amz-request-id': '7BAB893EDBA8CA84', 93 | }, ''); 94 | 95 | expect(props.setResponse.mock.calls[2][0]).toEqual( 96 | expect.objectContaining({ 97 | status: 200, 98 | responseText: 'https://lambda-s3-0.s3.us-east-2.amazonaws.com/pic.jpeg', 99 | }), 100 | ); 101 | }); 102 | 103 | it('on progress should call setProgress with the current upload status', () => { 104 | const { result } = renderHook(() => useUploadS3WithPresignedUrl(props)); 105 | act(() => { 106 | result.current(); 107 | }); 108 | 109 | const progress = new ProgressEvent('mockedProgress', { 110 | lengthComputable: true, 111 | loaded: 1, 112 | total: 10, 113 | }); 114 | 115 | requests[6].upload.onprogress(progress); 116 | 117 | expect(props.setProgress.mock.calls[0][0]).toEqual(100); 118 | expect(props.setProgress.mock.calls[1][0]).toEqual(10); 119 | }); 120 | }); 121 | 122 | afterAll(() => { 123 | global.XMLHttpRequest.restore(); 124 | }); 125 | -------------------------------------------------------------------------------- /docs/airbnb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastianserrano/react-use-s3/2fbb7070086f80d9bd527f47842a3627d83faaa0/docs/airbnb.png -------------------------------------------------------------------------------- /docs/react-s3-v2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastianserrano/react-use-s3/2fbb7070086f80d9bd527f47842a3627d83faaa0/docs/react-s3-v2.gif -------------------------------------------------------------------------------- /docs/react-s3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastianserrano/react-use-s3/2fbb7070086f80d9bd527f47842a3627d83faaa0/docs/react-s3.gif -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const useAWSScript = require('./dist/script/useAWSScript.js'); 2 | const useAWSUploadWithFile = require('./dist/upload/useAWSUploadWithFile.js'); 3 | 4 | module.exports = { 5 | useAWSScript, 6 | useAWSUploadWithFile, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-use-s3", 3 | "version": "1.0.3-beta", 4 | "description": "React hooks for working with an AWS S3 bucket", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/sebastianserrano/react-s3.git" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "hooks", 16 | "aws", 17 | "s3" 18 | ], 19 | "author": "Sebastian Serrano", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/sebastianserrano/react-s3/issues" 23 | }, 24 | "homepage": "https://github.com/sebastianserrano/react-s3#readme", 25 | "dependencies": { 26 | "react": "^16.8.6" 27 | }, 28 | "devDependencies": { 29 | "@testing-library/react-hooks": "^1.1.0", 30 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 31 | "eslint": "^5.16.0", 32 | "eslint-config-airbnb": "^17.1.1", 33 | "eslint-plugin-import": "^2.18.0", 34 | "eslint-plugin-jsx-a11y": "^6.2.3", 35 | "eslint-plugin-react": "^7.14.2", 36 | "jest": "^24.8.0", 37 | "react-test-renderer": "^16.8.6" 38 | } 39 | } 40 | --------------------------------------------------------------------------------