EmberJS File Uploads with S3 - 13 Feb 2016
There’s a lot of options out there for handling file uploads with EmberJS, but I’m going to go over my favorite option at the moment. It involves hosting your image on Amazon S3, but has the benefit of never sending the file to your server. Everything is handled on the client side using a signed request generated by your server.
The goal of this blog post will be to write an image-uploader
component
that will look something like:
{{image-uploader url=post.imageUrl}}
File Picker
The first thing we’re going to do is cheat a little by piggybacking off of an ember-cli file uploader addon. The one we’re going to use is ember-cli-file-picker. You can install this addon by running:
ember install ember-cli-file-picker
Image Uploader
Once that’s finished installing, we’re going to generate our image-uploader
component. This can be done by running:
ember g component image-uploader --pod
We can update our component template, so that it uses the file picker addon we
installed. Here’s what our app/components/image-uploader/template.hbs
should
look like.
{{#file-picker fileLoaded="fileLoaded" preview=false}}
Drag here or click to upload a file
{{/file-picker}}
You’ll note that we’re passing in a fileLoaded
action to the file-picker
component. We’ll need to define this action on your image-uploader
component,
and it will handle uploading our file whenever a new file is added.
Here’s a quick look at what our app/components/image-uploader/component.js
will look like with the action:
import Ember from 'ember'
const { set } = Ember
export default Ember.Component.extend({
actions: {
fileLoaded: function(file) {
set(this, 'file', file)
}
}
})
For now we’re simply storing the file on our component. We’ll need to add in functionality for uploading our file to S3 if we want our image uploader to be complete. We’re going to use two service objects for handling this process.
Signed Request Service - Ember
The first one we’re going to create is a signed-request
service. This service
will be responsible for fetching a signed request url from our server. Here’s
what our completed app/signed-request/service.js
file will look like:
import config from "../config/environment"
import Ember from 'ember'
export default Ember.Service.extend({
getUrl(fileName, fileType) {
return new Promise(function(resolve, reject) {
const url = `${config.API_HOST}/signed-request`
const params = { file: fileName, type: fileType }
jQuery.post(url, params, (data) => {
if (data.errors) reject(data.errors)
resolve(data)
})
})
}
})
A couple of pieces to notice about this service. The first one to pay attention
to is that our config/environment
file is expected to set a API_HOST
. I use
this property to set a different API host for each environment my application
will run in. For example:
if (environment === 'development') {
ENV.API_HOST = 'http://localhost:3000'
}
if (environment === 'production') {
ENV.API_HOST = 'https://mockra.com'
}
The next thing you’ll notice is that we also expect our server to handle a
route called /signed-request
. This is the route that will handle generating a
signed request that we’ll use to upload our file to Amazon S3. Our service also
expects a fileName
and fileType
as arguments.
Node Signed Request Example - Server
Here’s an example route for generating the signed-request
using Node/Koa. You
should be able to find documentation for the AWS library of your choice as
well. This example uses a few different files for setting up the AWS client, as
well as creating a signed-url.
util/s3-client.js
// Example Config Keys
s3Options: {
accessKeyId: process.env.S3_KEY,
secretAccessKey: process.env.S3_SECRET,
region: process.env.S3_REGION || 'us-west-1',
bucket: process.env.S3_BUCKET
}
const config = require('../config')
const aws = require('aws-sdk')
aws.config.update(config.s3Options)
const client = new aws.S3()
module.exports = client
util/s3-signed-url.js
const config = require('../config')
const client = require('./s3-client')
exports.getUrl = async (fileName, fileType) => {
return new Promise((resolve, reject) => {
const bucket = config.s3Options.bucket
const params = {
Bucket: bucket,
Key: fileName,
Expires: 60,
ContentType: fileType,
ACL: 'public-read'
}
client.getSignedUrl('putObject', params, function(err, data){
if (err) reject(err)
const returnData = {
signedRequest: data,
url: `https://${bucket}.s3.amazonaws.com/${fileName}`
}
resolve(returnData)
})
})
}
routes/s3.js
const signedUrl = require('../util/s3-signed-url')
router.post('/signed-request', async (ctx, next) => {
const body = ctx.request.body
const urlData = await signedUrl.getUrl(body.file, body.type)
ctx.body = urlData
})
S3 Upload Service - Ember
Now that we’ve gotten the signed-request
service and server response setup,
it’s time to create the service that will handle the actual upload. The first
thing we’ll need to do is generate that service. We can do so by running:
ember g service s3-upload --pod
The code for our app/s3-upload/service.js
will look like:
import Ember from 'ember'
export default Ember.Service.extend({
uploadFile(file, signedRequest) {
return new Promise(function(resolve, reject) {
const xhr = new XMLHttpRequest()
xhr.open("PUT", signedRequest)
xhr.setRequestHeader('x-amz-acl', 'public-read')
xhr.onload = () => { resolve() }
xhr.send(file)
})
}
})
Finishing our Image-Uploader Component
Once the necessary services are setup, we can add the final touches to our
image-uploader
component. The completed
app/components/image-uploader/component.js
file will look like:
import Ember from 'ember'
const { get, set, computed } = Ember
const { service } = Ember.inject
export default Ember.Component.extend({
signedRequest: service(),
s3Upload: service(),
uploadImage: async function() {
const fileName = `${get(this, 'file.name')}-${Date.now()}`
const fileType = get(this, 'file.type')
const signedData = await get(this, 'signedRequest')
.getUrl(fileName, fileType)
await get(this, 's3Upload')
.uploadFile(get(this, 'file'), signedData.signedRequest)
set(this, 'url', signedData.url)
},
actions: {
fileLoaded: function(file) {
set(this, 'file', file)
get(this, 'uploadImage').bind(this)()
}
}
})
The final image-uploader
will watch for a file being loaded through our
file-picker
addon. Once a file is selected, we’ll generate a signed-request
from our server. Once we have that, we’ll upload the file to S3, and finally
update the provided url.
You’ll notice that we’re appending Date.now()
to our fileName
. This is done
to prevent duplicate file names from conflicting. There’s a wide range of other
options for handling these issues, but this is one of the simpler solutions.
The best part about this approach is that we never have to worry about our API handling any file data.