Uploading Files Directly to Amazon S3 with User Profile Update

Last edit: Aug 16, 2019
  • Contributors:
  • pavelloz
  • Slashek

This guide will help you upload files directly to Amazon S3 using AJAX, and use them as user's avatar/banner saved in user profile.
It combines a simple HTML for updating a user profile with direct AWS S3 upload using AJAX.

Requirements

This is an advanced tutorial. To follow it, you should be familiar with basic platformOS concepts, HTML, Liquid, GraphQL, APIs and Forms, and the topics in the Get Started section.
This guide uses the same Form, user profile type, and page as Uploading Files Directly to Amazon S3 Using HTML Form.

Note

To make the code modern and short javascript code is written using ES2015 and assumes jQuery loaded.
To make it a little bit prettier it is assuming Bootstrap 4 loaded.

Steps

Uploading content directly to Amazon S3 with profile update is a three-step process:

Step 1: Prepare HTML form with fields including required metadata

forms/developer/update_profile.liquid

Create form, and set up all required data in hidden inputs (those will be read by JavaScript later):


{% if context.current_user.id %}
  {% graphql g = 'get_developer_images', user_id: context.current_user.id %}
{% endif %}
{% assign profile = g.user.profile %}
{% assign avatar_url = profile.avatar.url %}
{% assign banner_url = profile.banner.url %}
{% assign bio_field = form.fields.profiles.developer.properties.bio %}
{% assign image_s3_upload = avatar.image.s3_upload %}

{% form html-data-s3-direct-upload: 'form' %}
  {% for input in image_s3_upload.form_data %}
    <input type="hidden" name="{{ input[0] }}" value="{{ input[1] }}" data-s3-direct-upload-field="presign" disabled>
  {% endfor %}
  {% comment %} Presence of metadata field is required even if it is empty {% endcomment %}
  <input type="hidden" name="action" value="{{ image_s3_upload.direct_upload_url }}" disabled>

  <div class="row">
    <div class="col-12 col-md-4">

      <div class="card">
        <div class="card-header">Profile text field</div>
        <div class="card-body">
          <div class="form-group">
            <label for="{{ bio_field.name }}">My bio</label>
            <textarea name="{{ bio_field.name }}" class="form-control" id="{{ bio_field.name }}">{{ bio_field.value }}</textarea>
          </div>

          <hr>

          <label for="{{ avatar.image.name }}">Avatar</label>
          <input type="file" name="file" id="{{ avatar.image.name }}" data-s3-direct-upload-field-input="avatar">
          <input type="hidden" name="{{ avatar.image.name }}" data-s3-direct-upload-field-file-url="avatar">

          <div class="progress invisible mt-3" data-s3-direct-upload-progress="avatar">
            <div class="progress-bar progress-bar-striped bg-info progress-bar-animated" style="width: 100%"></div>
          </div>

          <hr>

          <label for="{{ banner.image.name }}">Banner</label>
          <input type="file" name="file" id="{{ banner.image.name }}" data-s3-direct-upload-field-input="banner">
          <input type="hidden" name="{{ banner.image.name }}" data-s3-direct-upload-field-file-url="banner">

          <div class="progress invisible mt-3" data-s3-direct-upload-progress="banner">
            <div class="progress-bar progress-bar-striped bg-info progress-bar-animated" style="width: 100%"></div>
          </div>

          <button class="btn btn-primary mt-3">Save changes</button>
        </div>
      </div>
    </div>

    <div class="col-12 col-md-8">
      <div class="card">
        <div class="card-header">Preview</div>

        <div class="row">
          <div class="col-6 border-right">
            <div class="card-body" data-s3-direct-upload-field-preview="avatar">
              {% if avatar_url %}
                <figure class="figure mr-3 pr-3">
                  <p class="text-muted">Current avatar</p>
                  <a href="{{ avatar_url }}" target="_blank">
                    <img src="{{ avatar_url }}" alt="{{ avatar_url }}" width="200" class="figure-img img-fluid rounded">
                  </a>
                </figure>
                <hr class="hr">
              {% endif %}
            </div>
          </div>
          <div class="col-6">
            <div class="card-body" data-s3-direct-upload-field-preview="banner">
              {% if banner_url %}
                <figure class="figure mr-3 pr-3">
                  <p class="text-muted">Current banner</p>
                  <a href="{{ banner_url }}" target="_blank">
                    <img src="{{ banner_url }}" alt="{{ banner_url }}" width="200" class="figure-img img-fluid rounded">
                  </a>
                </figure>
                <hr class="hr">
              {% endif %}
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
{% endform %}

<script src="{{ 'UpdateProfileWithDirectUpload.js' | asset_url }}" defer></script>


Step 2: Prepare JavaScript that will upload files to S3

UpdateProfileWithDirectUpload.js file contains all necessary JavaScript needed to handle file sending via ajax, error handling, logic handling form fields and preview display.

assets/UpdateProfileWithDirectUpload.js

const qa = s => Array.prototype.slice.call(document.querySelectorAll(s));
const q = s => document.querySelector(s);
const getXMLText = (data, key) => $(data).find(key).text() || '';

class FileUpload {
  constructor({ name }) {
    this.name = name;

    this.fileInput = q(`[data-s3-direct-upload-field-input="${this.name}"]`);
    this.presignFields = qa('[data-s3-direct-upload-field="presign"]');

    this.form = $(this.fileInput).closest('form');
    this.action = this.form.find('[name="action"]').val();
    this.fileUrl = q(`[data-s3-direct-upload-field-file-url="${this.name}"]`);
    this.progress = this.form.find(`[data-s3-direct-upload-progress="${this.name}"]`);
    this.previewContainer = this.form.find(`[data-s3-direct-upload-field-preview="${this.name}"]`);

    this.attachEventHandlers();
  }

  onFileChange() {
    this.sendForm()
      .done(data => (this.fileUrl.value = getXMLText(data, 'Location'))) // save path to uploaded file in db
      .done(() => this.fileInput.setAttribute('disabled', 'disabled')) // do not submit files in form, since they are not used
      .done(this.updatePreview.bind(this)) // update preview to show what has been uploaded to s3
      .always(() => (this.showProgressBar = false));
  }

  attachEventHandlers() {
    $(this.fileInput).on('change', this.onFileChange.bind(this));
  }

  get fileName() {
    return this.fileInput.value.split('/').pop().split('\\').pop();
  }

  get fileSize() {
    return this.fileInput.files[0].size;
  }

  updatePreview(data) {
    const imageUrl = getXMLText(data, 'Location');

    const previewHtml = `
      <figure class="figure mr-3">
        <p class="text-muted">Newly uploaded ${this.name}</p>
        <a href="${imageUrl}" target="_blank">
          <img src="${imageUrl}" class="figure-img img-fluid rounded" width="200">
        </a>
        <figcaption class="figure-caption">
          Name: ${this.fileName}
          <br/>
          Size: ${this.fileSize} bytes
        </figcaption>
      </figure>
    `;

    this.previewContainer.append(previewHtml);
  }

  get formData() {
    const formdata = new FormData();
    this.presignFields.forEach(field => formdata.append(field.name, field.value));
    formdata.append(this.fileInput.name, this.fileInput.files[0], this.fileName);
    return formdata;
  }

  set showProgressBar(showOrHide) {
    this.progress.toggleClass('invisible', !showOrHide);
  }

  sendForm() {
    return $.ajax({
      type: 'post',
      url: this.action,
      contentType: false,
      processData: false,
      beforeSend: () => (this.showProgressBar = true),
      data: this.formData
    });
  }
}

new FileUpload({ name: 'avatar' });
new FileUpload({ name: 'banner' });

Step 3: Submit form with urls to S3 images as values of avatar and banner fields

Form is now ready to update three different fields on developer profile: bio (string), avatar (image), and banner (image).

To test the form:

  • Log in to your developer account (or create account if you don't have it already)
  • Insert new text into Bio field
  • Upload new avatar and banner images (observe preview to be updated with source to s3 bucket - later on it will be replaced to CDN url) - after the image has been uploaded different versions are generated, it gets compressed and put onto the CDN - it takes a couple of seconds
  • Submit form by clicking Save changes
  • After successful submit images should be loaded from CDN using transformed version (compressed) - if they are not updated refresh after couple seconds

Important notes / troubleshooting

General

  • Instead of sending an image to our server you will send a url (returned by S3 response) to the image inside the text field for given image. Image URL should look similar to https://near-me-oregon-staging.s3-us-west-2.amazonaws.com/uploads%2Fbc50d6da-c17e-477d-b143-c65422c221fd%2Ftest.3+%282%29.png
  • After form submission give our server couple of seconds to pickup your image, generate size versions, compress them and upload to the new S3 location so the CDN could pick it up for you and datatabase to be updated with CDN links

If you have problems that you cannot figure out, go to live example page, inspect whats being sent to S3 and our server respectively and find differences between your requests and those that work - the difference usually is whats broken

AWS

  • Make sure your form is using POST method
  • Make sure your form is sending data as enctype="multipart/form-data"
  • Do not include any fields that are not listed in the s3_upload key, otherwise AWS will return error 403 with message Invalid according to Policy: Extra input fields: xxx
  • Make sure file input is sent last in the form
  • Remember to set your file input name to file

Read more about aws requirements.

Live example and source code

To play with a live example, create a developer account at https://examples.platform-os.com and go to the update_profile page.

Source code can be found on GitHub.

Additional resources

Questions?

We are always happy to help with any questions you may have.