Uploading images to S3 in Ember.js – Part 1: Architecture & Setup

09 December 2021

Uploading images in web forms is a topic that has fascinated me for a long time. For some reason, though, I never took the time to deepen my understanding and knowledge – that reason probably being that there are countless exciting topics in web development, not to mention outside web development.

I now started to look into how one implements image uploads "from first principles" (without using an add-on that just does it for you) and the related handling of images. I chose to use S3 as the image storage and upload back-end mostly because it's popular and I've known a little bit about S3 buckets.

In this first post, we'll see:

Let's jump in.

The high-level architecture

AWS allows direct uploads to S3 buckets so that your back-end doesn't need to be involved which it makes possible through so-called "pre-signed" URLs. You hit a dedicated AWS endpoint to generate such a URL which has the authentication "built in" (with the use of AWS-specific authentication headers). You then use that URL within a certain timeframe to upload the image (or any file, really).

The following sketch should give you a better idea how that works:

High-level sketch of image upload architecture

AWS setup

You'll need a correctly configured S3 bucket to store files in. I followed the set up laid out in this post but I'll briefly summarize here:

1. You should create a dedicated IAM user in AWS whose credentials you'll use for the whole process.

I called it uploader and gave the user the AmazonS3FullAccess:

High-level sketch of image upload architecture

You'll need the access key id and secret access key of that user later, for generating the pre-signed URL.

2. Create an S3 bucket with the appropriate bucket policy

The policy should allow everybody to read from the bucket folder the images are uploaded in.

It looks something like this:

 1{
 2    "Version": "2012-10-17",
 3    "Id": "Policy1637241320792",
 4    "Statement": [
 5        {
 6            "Sid": "AllowReadingUploadedFiles",
 7            "Effect": "Allow",
 8            "Principal": "*",
 9            "Action": "s3:GetObject",
10            "Resource": "arn:aws:s3:::rarwe-dev/image-uploads/*"
11        }
12    ]
13}

The Principal is not the head of the school, but identifies who is allowed to access the bucket objects. In this case, it's everybody.

This only gives the grand public permission to access the objects in the image-uploads folder where the images will be stored. Note the trailing slash and asterisk in arn:aws:s3:::rarwe-dev/image-uploads/* - we want to give read permission not only to the folder itself but all objects within that folder.

3. CORS setup

Since this bucket will be accessed from different domains, we need to define where requests are accepted from, which http verbs are accepted, and so on.

 1[
 2    {
 3        "AllowedHeaders": [
 4            "*"
 5        ],
 6        "AllowedMethods": [
 7            "GET",
 8            "POST",
 9            "PUT"
10        ],
11        "AllowedOrigins": [
12            "http://localhost:*",
13            "https://app.rockandrollwithemberjs.com"
14        ],
15        "ExposeHeaders": [
16            "Location"
17        ]
18    }
19]

With this configuration, any localhost port will be accepted as Origin which allows us to use any port number when developing locally. The deployed app lives at https://app.rockandrollwithemberjs.com, so a corresponding item needs to be added to AllowedOrigins.

Exposing the Location header is needed since that's how AWS shared the full URL of the uploaded file in the response of the pre-signed URL (we'll see more about that later).

The back-end

If you take a look at the architecture sketch above, you'll see that the sole job of the back-end is to create the pre-signed URL, consulting with S3. It's usually not a good idea to store secrets in a front-end application and the secret access key of our IAM user definitely falls in this category so that's why we need to do this in a back-end.

The API serving the Rock & Roll with Ember.js app is a Rails app so I resisted the urge to create a micro-service and simply added a controller action for generating the pre-signed URL (I stole this from another great post on the subject):

 1class UploadsController < ApplicationController
 2  def presign
 3    aws_credentials = Aws::Credentials.new(
 4      ENV['AWS_ACCESS_KEY_ID'],
 5      ENV['AWS_SECRET_ACCESS_KEY']
 6    )
 7
 8    s3_bucket = Aws::S3::Resource.new(
 9      region: ENV['S3_REGION'],
10      credentials: aws_credentials
11    ).bucket(ENV['S3_BUCKET'])
12
13    presigned_url = s3_bucket.presigned_post(
14      key: "image-uploads/#{SecureRandom.uuid}/${filename}",
15      success_action_status: '201',
16      signature_expiration: (Time.now.utc + 15.minutes)
17    )
18    data = { url: presigned_url.url, url_fields: presigned_url.fields }
19    render json: data, status: :ok
20  end
21end

To make this work, I needed to install the aws-sdk-s3 gem

All the secrets and configurable bits come from environment variables.

That's all there is to the back-end components: we're ready to write some front-end code and put the pieces together.

The Ember bits - uploading the actual image

We'll add an file upload field to the "New band" form made in the book:

 1<form method="post" enctype="multipart/form-data">
 2  <div class="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
 3    {{!-- Name field --}}
 4    <div class="sm:col-span-4">
 5      <label for="image" class="block text-sm font-medium text-gray-100">
 6        Image
 7      </label>
 8      {{#if this.imagePreviewSrc}}
 9        <div class="mt-1">
10          <img src={{this.imagePreviewSrc}} alt="Band image preview" />
11        </div>
12      {{else}}
13        <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
14          <div class="space-y-1 text-center">
15            <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
16              <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
17            </svg>
18            <div class="flex text-sm text-gray-300">
19              <label for="file-upload" class="relative cursor-pointer rounded-md font-medium text-blue-400 hover:text-blue-300 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-purple-400">
20                <span>Upload a file</span>
21                <input
22                  id="file-upload" name="file-upload" type="file" class="sr-only"
23                  accept="image/jpeg, image/png, image/gif"
24                  {{on "change" this.didUploadImage}}
25                />
26              </label>
27              <p class="pl-1">or drag and drop</p>
28            </div>
29            <p class="text-xs text-gray-400">
30              PNG, JPG, GIF up to 10MB
31            </p>
32          </div>
33        </div>
34      {{/if}}
35    </div>
36  </div>
37  {{!-- Save button --}}
38</form>

The input type needs to be file for file uploads. The above snippet gives us a decent looking form with a file input where we can drag & drop our image or select it manually from our file system:

New band form with file input for image upload

Preview the selected image

The branch where this.imagePreviewSrc is true serves to show a preview if an image has been selected. We know this by listening to the change event of the input. It follows, then, that we should set the imagePreviewSrc to a displayable image path in the didUploadImage action.

We'll create a "blob", a local object for this purpose:

 1// app/controllers/bands/new.js
 2export default BandsNewController extends Controller {
 3  // (...)
 4  @tracked imagePreviewSrc;
 5  // (...)
 6  @action
 7  didUploadImage(event) {
 8    let [file] = event.target.files;
 9    this.imagePreviewSrc = URL.createObjectURL(file);
10  }
11}

The files property of the event always contains a list of files to accommodate for being able to choose multiple files to upload. In our case, we allow a single image, so we just take the first item. URL.createObjectURL is the browser method to create a blob URL for a file.

Here is what we see after selecting an image:

Preview after selecting the image

Upload the selected image

We're now ready to upload the image.

As shown above on the sketch, we'll first have to make a request to our back-end to obtain a pre-signed URL to our S3 bucket and then make a request to that URL to upload the image directly.

 1// app/controllers/bands/new.js
 2export default BandsNewController extends Controller {
 3  // (...)
 4  @action
 5  async saveBand(event) {
 6    event.preventDefault();
 7    let response = await fetch('/presign-aws-request', {
 8      method: 'POST',
 9    });
10    let { url, url_fields: urlFields } = await response.json();
11
12    let formData = new FormData();
13    for (let field in urlFields) {
14      formData.append(field, urlFields[field]);
15    }
16    formData.append('file', this.imageToUpload);
17
18    let imageUploadResponse = await fetch(url, {
19      method: 'POST',
20      body: formData,
21    });
22
23    let bandProperties = {
24      name: this.name,
25    };
26
27    if (imageUploadResponse.ok) {
28      bandProperties['image-url'] = imageUploadResponse.headers.get('Location');
29    }
30    let band = await this.catalog.create('band', bandProperties);
31    // (...)
32  }
33}

The back-end endpoint to create the pre-signed URL is available at /presign-aws-request. You'll notice that we don't need any parameters, or payload: a simple POST to the endpoint will return the S3 bucket url and the fields we need to set on the request.

Because we assemble the request via JavaScript, cancelling the default behavior of encoding the form as "multipart/form-data", we need to create the same encoding before sending the payload: that's what appending fields to a FormData object achieves.

If the image upload to the S3 bucket was successful, we extract the location of the image in the bucket from the Location header (that's why this needed to be allowed in the CORS config above), and create the new band on the back-end with the name and image-url properties.

The image-url property is now part of the band's attributes and will be sent by the back-end if it's present.

Finally, bands can now have an image:

Band details can now include an image

Things I got wrong during development

Above the smaller bumps along the way, there were a few challenges that took more head-scratching to solve. I list them below to give a more realistic picture of the development process as it all seems deceivingly straightforward in the linear, smooth flow of a blog post.

  1. I got a CORS error when trying to upload an image to S3. It turned out it had nothing to do with CORS: the bucket region was incorrectly configured in the back-end so the client attempted to upload to a different bucket (that had no CORS config in place).
  2. I still couldn't upload the image after that: I didn't use FormData as the body of the request and thus the expected encoding, multipart/form-data was not used which resulted in AWS sending back an error.
  3. URLs of uploaded images didn't work: img tags using them as src didn't display. This was due to a wrong bucket policy: instead of defining arn:aws:s3:::rarwe-dev/image-uploads/* as the Resource of the bucket policy, I only had arn:aws:s3:::rarwe-dev/image-uploads which didn't give read access to child objects within that folder.

What's next?

What we have working so far is just the first step.

We don't have any validation of the images, or error handling. Images can not be removed, or updated and they are not displayed anywhere (for the last screenshot I stepped ahead of the blog post). So there is definitely material for at least one more blog post – stay tuned!

Share on Twitter