EvaporateJS is a JavaScript library for uploading files to a S3 bucket using multipart uploads. You can pause/resume or cancel an upload.
I’ve tested it with Minio before moving to AWS or Scaleway.
Prerequisites
Install dependencies
You have to install the following applications :
- Minio
- NodeJS and NPM
You can use brew or any other package manager following you’re running on MacOS or Linux.
Start Minio
Once Minio is installed you can start it with the following command :
#!/bin/sh
export MINIO_ACCESS_KEY='<S3_ACCESS_KEY>'
export MINIO_SECRET_KEY='<S3_SECRET_KEY>'
export MINIO_REGION_NAME='us-east-1'
minio server --address '127.0.0.1:80' /path/to/data
Note: Minio is started on port 80 to avoid a bug with S3 signature
To get more info about the setup please read the Minio documentation
The architecture
Evaporate needs a specific endpoint to sign its HTTP request with the secret key before sending it to the S3 bucket
The GitHub repository includes many examples of endpoint implemented in Python, JavaScript, Java or Go.
Generating the S3 signature with GO
You must generate a signature for your S3 request before sending it. The method is documented here.
This work is delegated to a sign server. The GitHub repository of EvaporateJS contains many examples implemented in Python, JS, Java or Go. I’ve choose go for my sample.
package main
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"log"
"net/http"
"strings"
)
var (
date string
regionName string
serviceName string
requestName string
)
// Replace <S3_SECRET> by the secret associated to your access key
// If you're using minio this is the value of env variable MINIO_SECRET_KEY
const AWS_SECRET string = "<S3_SECRET>"
func HMAC(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
func derivedKey(t string) []byte {
h := HMAC([]byte("AWS4"+AWS_SECRET), []byte(t))
h = HMAC(h, []byte(regionName))
h = HMAC(h, []byte(serviceName))
h = HMAC(h, []byte(requestName))
return h
}
func signature(t, sts string) string {
h := HMAC(derivedKey(t), []byte(sts))
return fmt.Sprintf("%x", h)
}
func main() {
http.HandleFunc("/sign_auth", func(rw http.ResponseWriter, r *http.Request) {
qs := r.URL.Query()
strs := strings.Split(qs.Get("to_sign"), "\n")
data := strings.Split(strs[2], "/")
date, regionName, serviceName, requestName = data[0], data[1], data[2], data[3]
signedKey := signature(date, qs.Get("to_sign"))
rw.Header().Add("Access-Control-Allow-Origin", "*")
rw.Write([]byte(signedKey))
});
log.Fatal(http.ListenAndServe(":8080", nil))
}
Note: This is a really basic endpoint implementation you should add some security before deploying it in production.
Using Evaporate.js into your JavaScript application
For this section, I’ve created a sample application. Here is the content of file package.json:
{
"name": "s3-upload",
"version": "0.0.1",
"description": "Sample application for upload files to S3 with Evaporate.JS",
"private": true,
"scripts": {
"build": "webpack",
"start": "webpack serve"
},
"author": "Mickaël GREGORI",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.12.1",
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.1",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.0.0",
"dotenv": "^8.2.0",
"html-webpack-plugin": "^4.5.0",
"style-loader": "^2.0.0",
"webpack": "^5.2.0",
"webpack-cli": "^4.1.0",
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"bootstrap": "^4.5.3",
"evaporate": "^2.1.4",
"jquery": "^3.5.1",
"js-sha256": "^0.9.0",
"popper.js": "^1.16.1",
"spark-md5": "^3.0.1"
}
}
You may noticed that I use webpack to run the application. The content of webpack configuration file is :
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const dotenv = require('dotenv');
module.exports = {
mode: 'development',
entry: './src/index.js',
devtool: 'inline-source-map',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3000,
hot: true
},
plugins: [
new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
new HtmlWebpackPlugin({
title: 'Development',
template: 'index.html'
}),
new webpack.DefinePlugin({
'process.env': JSON.stringify(dotenv.config().parsed)
}),
],
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{ test: /\.css$/i, use: ['style-loader', 'css-loader'] },
{ test: /\.m?js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } }
]
}
};
dotenv is required to configure our sample application. Evaporate needs your access key, the region and the name of your S3 bucket. More over the URL of the sign server is also required. All these information are put into a file named .env and loaded by dotenv.
Here is the content of file .env:
NODE_ENV=development
SIGNER_URL=<SIGN_SERVER_URL>
AWS_ACCESS_KEY=<S3_ACCESS_KEY>
AWS_S3_BUCKET_NAME=<S3_BUCKET_NAME>
AWS_URL=<S3_BUCKET_URL>
- SIGN_SERVER_URL is the URL of our sign server. Usually it’s http://localhost:8080/sign_auth
- S3_ACCESS_KEY is your access key
- S3_BUCKET_NAME is the name of your S3 bucket
- S3_BUCKET_URL is the URL of Minio server into our sample
1- Start the sign server
To start the sign server run the following command
$ go run main.go
2- Build and start the web application
First we need a HTML file. Here is the content of index.html:
<!DOCTYPE html>
<html>
<head>
<title>S3 Upload</title>
</head>
<body>
<main class="m-3">
<section id="upload-form">
<form>
<input id="file-upload" type="file" name="file"></input>
<button id="upload" name="upload">Upload</button>
</form>
</section>
<div id="drag-zone" class="d-block p-3 mt-2 w-50 h-50 bg-warning shadow rounded">
Drag & drop files here
</div>
</main>
</body>
</html>
There is a form where you can select your file and click on a button to upload it. There is also a drag and drop zone.
Whatever the technique you choose the function uploadFile will be called. Here is the content of file index.js:
import Evaporate from 'evaporate';
import sparkMD5 from 'spark-md5';
import sha256 from 'js-sha256';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap';
import * as $ from 'jquery';
const { SIGNER_URL, AWS_ACCESS_KEY, AWS_S3_BUCKET_NAME, AWS_URL } = process.env;
const uploader = Evaporate.create({
signerUrl: SIGNER_URL || '/auth/signv4_upload',
aws_url: AWS_URL || 'http://127.0.0.1',
aws_key: AWS_ACCESS_KEY || 'AWS_PUBLIC_KEY',
bucket: AWS_S3_BUCKET_NAME || 'videos',
cloudfront: false,
sendCanonicalRequestToSignerUrl: false,
computeContentMd5: true,
cryptoMd5Method: (d) => btoa(sparkMD5.ArrayBuffer.hash(d, true)),
cryptoHexEncodedHash256: sha256,
});
function onUploadProgress(percent, stats) {
// replace arrow function by a regular one because of this keyword
const filename = this.name;
console.log('Progress', filename, percent, stats);
};
const onUploadComplete = (xhr, awsObjectKey) => {
console.log('Complete!', awsObjectKey);
};
const onUploadError = (mssg) => {
console.log('Error', mssg);
};
const onUploadPaused = () => {
console.log('Paused');
};
const onUploadPausing = () => {
console.log('Pausing');
};
const onUploadResumed = () => {
console.log('Resumed');
};
const onUploadCancelled = () => {
console.log('Cancelled');
};
const onUploadStarted = (fileKey) => {
console.log('Started', fileKey);
};
const onUploadInitiated = (s3Id) => {
console.log('Upload Initiated', s3Id);
};
const onUploadWarn = (mssg) => {
console.log('File did not upload successfully:', reason);
};
const uploadFile = (file) => {
console.log(file);
uploader.then((evaporate) => {
evaporate.add({
file: file,
name: file.name,
progress: onUploadProgress,
complete: onUploadComplete,
error: onUploadError,
paused: onUploadPaused,
pausing: onUploadPausing,
resumed: onUploadResumed,
cancelled: onUploadCancelled,
started: onUploadStarted,
uploadInitiated: onUploadInitiated,
warn: onUploadWarn
}).then(
(awsObjectKey) => console.log('File successfully uploaded to ', awsObjectKey),
(reason) => console.log('File did not upload successfully: ',reason)
);
});
};
$('#drag-zone').on('dragenter', (e) => {
e.stopPropagation();
e.preventDefault();
});
$('#drag-zone').on('dragover', (e) => {
e.stopPropagation();
e.preventDefault();
});
$('#drag-zone').on('drop', (e) => {
e.stopPropagation();
e.preventDefault();
const dt = e.originalEvent.dataTransfer;
const files = dt.files;
console.log(files);
if (files) {
Array.from(files).forEach(uploadFile);
}
});
$('#upload').click( (e) => {
e.preventDefault();
let inputFiles = $("#upload-form form > input[type='file']");
if (inputFiles && inputFiles.length) {
let files = inputFiles[0].files;
if (files.length) {
Array.from(files).forEach(uploadFile);
}
}
e.stopPropagation();
});
To start the application run the following command:
$ npm start
Great :) here we are :