mockra

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.