Drag and Drop Direct to S3 Active Storage File Uploader
Tags: rails, ruby, fileupload, s3
Published: 2023-08-11 00:49:47

---
title: Drag and Drop Direct to S3 Active Storage File Uploader
published: true
tags: RubyonRails, Ruby, FileUpload, S3
---

You want a fast fancy uploader to be an easy thing to implement. But it gets tricky. I'll do my best to explain how I built the Uploader for [Musicbase](https://musicbase.app) using ActiveStorage and Direct Uploads to S3.

![Direct to S3 Uploader](https://img.avi.nyc/CNsFwWFR+)

It might get complex and there are a lot of steps, but its worth it and you can totally do it.

If you want more resources on the topic, [Chris has a great video on Activestorage with Direct Uploads](https://gorails.com/episodes/direct-uploads-with-rails-active-storage).

## The Setup

If you want to follow along, the first thing I did was clone [my Rails Starter](https://github.com/aviflombaum/avis-rails-starter) template. But any Rails app will do. The only thing that's special about my template for the sake of this demo is that it is using importmaps to manage JS dependencies. But we're not going to have any dependencies so it shouldn't matter.

[You can follow along by following the commits in the series github repo](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commits/main).

## Setup Your Model
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/c3807d1ac9b84910122031ad5aa6093a6fa49953)

We're going to use a `Track` model where a track will have a filename, title and artist_name. We'll also add a `has_one_attached :audio_file` to the model.

```
rails g model Track filename title artist_name
```

Then let's install ActiveStorage.

```
bin/rails active_storage:install
```


And finally lets add the `has_one_attached :audio_file` to the model.

`app/models/track.rb`
```ruby
class Track < ApplicationRecord
  has_one_attached :audio_file
end
```

Run your migrations with `rails db:migrate`.

Let's make sure this worked by uploading an mp3 file to a new track in the console.

```
track = Track.new
track.audio_file.attach(io: File.open("./spec/fixtures/Plastikbeat - Babarabatiri Loop (Original Mix).mp3"), filename: "01 - The Beatles - I Saw Her Standing There.mp3")
track.save
```

If you want that file, you can [grab it here](https://avinyc.s3.amazonaws.com/blog-posts/activestorage-s3-direct-uploader/Plastikbeat%20-%20Babarabatiri%20Loop%20%28Original%20Mix%29.mp3) and put it in `spec/fixtures`.

![Track with audio file attached](https://img.avi.nyc/YHrn6PHZ+)

## Setup S3
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/024dee8c49196db6d7cd8e94c8accc9dce1388e3)

The next step is setting up S3. This has been covered in a lot of places, the [Rails Guide](https://guides.rubyonrails.org/active_storage_overview.html#s3-service-amazon-s3-and-s3-compatible-apis) are fine. Just make sure to configure the corect CORS settings for a direct upload.

And don't forget to add the aws-sdk-s3 gem with `bundle add aws-sdk-s3`.

### Configure the Correct CORS for Your Bucket

Make sure you configure the correct CORS settings to allow direct uploads. Again, the [Rails Guides on CORS](https://guides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration-for-amazon-s3) are fine. Just make sure you edit the `AllowedOrigins` section. My CORS looked like this:

```json
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT"
        ],
        "AllowedOrigins": [
            "http://localhost",
            "http://127.0.0.1",
            "http://localhost:3000",
            "http://127.0.0.1:3000"
        ],
        "ExposeHeaders": [
            "Origin",
            "Content-Type",
            "Content-MD5",
            "Content-Disposition"
        ],
        "MaxAgeSeconds": 3600
    }
]
```

Okay, I'm assuming that you setup S3 and changed your ActiveStorage settings to use S3/Amazon.

If you've done all that you should be able to create another track in your console as we did above and this time, after you `track.save`, try calling `track.audio_file.url`. You should get back a nice looking S3 URL.

## The View
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/2a5bbf3fcf7920ee465f13ff92172caa3494d5b5)

Let's setup our view.

### `overmind`

If you're using my starter template, I use `overmind` to manage my processes. If you're not using overmind, I highly recommend it. [Learn how to use overmind instead of foreman](https://blog.testdouble.com/posts/2020-03-17-improving-dev-experience-with-overmind/) or here Without overmind, you can still run `rails s` but you should also run `bin/rails tailwindcss:watch` in another terminal.

![Homepage](https://img.avi.nyc/D4C4jndx+)

If your app is working, you should see my nice starter page.

### Tracks Controller

To get our views going, let's generate a `tracks_controller` with a `new` and `create` action.

```
rails g controller tracks new create
```

Let's go over to http://localhost:3000/tracks/new

To make our view, we're going to do two things.

First, let's drop the HTML we're going to use in our view.

`app/views/tracks/new.html.erb`

```html
<div class="w-full">
  <div class="p-6">
    <div id="uploadzone">
      <button
        class="w-full rounded-lg bg-muted border-2 border-dashed border-gray-300 p-8 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
        <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto h-12 w-12 text-gray-400"><path d="M17.5 22h.5c.5 0 1-.2 1.4-.6.4-.4.6-.9.6-1.4V7.5L14.5 2H6c-.5 0-1 .2-1.4.6C4.2 3 4 3.5 4 4v3"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z"></path><path d="M6 20v-1a2 2 0 1 0-4 0v1a2 2 0 1 0 4 0Z"></path><path d="M2 19v-3a6 6 0 0 1 12 0v3"></path></svg>
        <span class="mt-2 block text-sm font-semibold text-gray-900 dark:text-white">Drag Tracks to Upload or Click Here</span>
      </button>
    </div>
  </div>
  <div id="uploads" class="flex flex-col overflow-y-scroll divide-y border h-[calc(100vh-18em)]">
  </div>
</div>
```

That's just a bunch of tailwind stuff but it'll make a pretty nice looking upload zone page with a locked container to store all the files that get uploaded in a scrollable window. It'll look like this:

![Upload Zone](https://img.avi.nyc/PwbQWy8t+)

You also have to open `app/views/layouts/application.html.erb` and change the margin top from 28 to 8 using `mt-8`.

## Building the Upload Zone

We need to have our upload zone be able to accept files. We're going to use the [HTML5 File API](https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications) to do this but it's not that complicated. You could always use a great library like Dropzone.js, but I accidentally promised this would be dependency free even though I used Dropzone.js for production.

## Triggering the File Upload
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/4f0fc01af8d6ecba026dbf9428dcddfe17274f01)

We need to make it so that when you click on the uploadzone it triggers the familiar File Upload dialog. We're going to do this with a little bit of JavaScript.

The first step is adding a hidden file input field to our HTML, `<input type="file" class="hidden" multiple />`.

Let's now create a stimulus controller to handle the upload zone.

```
rails g stimulus uploadzone
```

Let's build a method in the controller to handle the triggering of the file upload dialog.

`app/javascript/controllers/uploadzone_controller.js`

```js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {}

  trigger() {
    this.element.querySelector("input[type=file]").click();
  }
}
```

Before we talk about refactoring that at all, let's add the controller and action to our HTML.

Let's make the entire uploadzone div bound to the stimulus controller and add a click event to it.

Edit `app/views/tracks/new.html.erb` and add the following to the uploadzone div.
```html
<div id="uploadzone" data-controller="uploadzone" data-action="click->uploadzone#trigger">
```

Now, when you click on the uploadzone div, it'll trigger the `trigger` method in the controller which will click the hidden file input which will trigger the dialog.

![Triggering the Dialog](https://img.avi.nyc/00hcQ2sv+)

Lovely.

### Refactor to Use a Target

Instead of doing our `querySelector`, let's make the hidden file input a target of our controller named `fileInput`.

`app/javascript/controllers/uploadzone_controller.js`

```js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["fileInput"];
  connect() {}

  trigger() {
    this.fileInputTarget.click();
  }
}
```

And in our HTML, to bind the file input as a target:

`app/views/tracks/new.html.erb`
```html
<input type="file" class="hidden" multiple data-uploadzone-target="fileInput">
```

Everything should still work.

## Drag and Drop
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/6c5df3bbf1d5e522b40c7095dabfc2377a76ff33)

### Preventing Default Drag and Drop Behavior

Now that we have the file input triggering, let's make it so that we can drag and drop files into the uploadzone.

In order to do this, the first thing we need to do is prevent the browser from doing what it normally does when you do a drag and drop.

Let's add this to our stimulus controller:

`app/javascript/controllers/uploadzone_controller.js`

```js
connect() {
  this.element.addEventListener("dragover", this.preventDragDefaults);
  this.element.addEventListener("dragenter", this.preventDragDefaults);
}

disconnect() {
  this.element.removeEventListener("dragover", this.preventDragDefaults);
  this.element.removeEventListener("dragenter", this.preventDragDefaults);
}

preventDragDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}
```

Because our uploadzone is bound to the entire div we want to monitor for the drag and drop, we can do this in our `connect` hook. `this.element` represents that uploadzone div, the element of the controller. We're adding listeners to dragover and dragenter which will tell the browser to not do what they normally do by calling our `preventDragDefaults` method.

When the controller disconnects, let's clean that up by removing the listeners.

You should be able to drag a file into the space and see nothing happen (as opposed to the browser opening your file).

### Accepting Files from the Drag and Drop

Now that we've prevented the browser from doing what it normally does, we need to accept the files that are being dragged into the uploadzone. Let's bind another action to our uploadzone div, `drop->uploadzone#acceptFiles`.

`app/views/tracks/new.html.erb`
```html
    <div id="uploadzone" data-controller="uploadzone" data-action="click->uploadzone#trigger drop->uploadzone#acceptFiles">
```

That will make it so that when we drop files into the uploadzone, it'll call the `acceptFiles` method in our controller.

`app/javascript/controllers/uploadzone_controller.js`
```js
acceptFiles(event) {
  event.preventDefault();
  const files = event.dataTransfer.files
  debugger;
}
```

We're going to use the `dataTransfer` property of the event to get the files that were dropped into the uploadzone. If you now drop a file into the uploadzone, you'll see that the `files` variable is an array of files in your debugger.

## Quick Recap

1. Set up the HTML for the uploadzone.
2. Set up the stimulus controller to handle the triggering of the file upload dialog.
3. Set up the stimulus controller to handle the drag and drop of files into the uploadzone.
4. Set up the stimulus controller to accept the files that were dragged into the uploadzone.

Our drag and drop left us with a nice array of files that we're going to be able to pass off to some function that handles uploading them. **However, we didn't get left with a nice array of files after the click of the uploadzone.** Let's fix that so that we have parity between the events, at the end of the file dialog and at the end of the drag and drop we should be left with a nice array of files to pass off to some upload function.

## Getting Files from the File Dialog
[Commit](https://github.com/aviflombaum/activestorage-s3-direct-uploader/commit/e92b1f299c64bd1f64e666ce82f457461ab090a1)

The file dialog when completed changes the value of the file input. We can use that to get the files that were selected. We're actually going to be able to re-use our `acceptFiles` method to do this after a slight modification.

Let's add a `change` event to our file input in our HTML.

`app/views/tracks/new.html.erb`
```html
<input type="file" class="hidden" multiple data-uploadzone-target="fileInput" data-action="change->uploadzone#acceptFiles">
```

Now when that input is changed it will send that event to our `acceptFiles` method.

What we want to do is modify our acceptFiles to either look for drag and drop dataTransfer files or from files from the file input.

`app/javascript/controllers/uploadzone_controller.js`
```js
acceptFiles(event) {
 event.preventDefault();
 const files = event.dataTransfer ? event.dataTransfer.files : event.target.files;
 debugger;
}
```

If `event.dataTransfer` exists, we know the event was the drag and drop and we can get the files from the `dataTransfer`. Otherwise, we know the event was from the file input and we can get the files from the `target.files`.

## What We've Built

At this point we've basically built a Drag and Drop file upload component. In fact, we built the [shadcn on rails](https://shadcn.rails-components.com) [dropzone component](https://shadcn.rails-components.com/docs/components/dropzone).

Cool, right? Now we're ready for the upload which we will cover in [part 2](https://code.avi.nyc/an-activestorage-s3-direct-uploader-part-2-direct-upload-to-s3) tomorrow.