Direct File Uploads to Amazon S3 with Phoenix LiveView
Tags: elixir, phoenix
Published: 2024-04-02 12:46:08

In this post, we'll add file upload capabilities to a Phoenix LiveView application and directly upload files to Amazon S3.

Without further ado, let's get started!

## Setting Up Our Phoenix LiveView Project

Our example will upload behavior to an existing application with a “create puppy” feature. We're going to go through the [Phoenix LiveView Uploads guide](https://hexdocs.pm/phoenix_live_view/uploads.html), making small adjustments as we go.

To begin, we need to set up our project. You can skip this step if you already have an existing Phoenix LiveView application. Otherwise, you can create a new project by running the following command in your terminal:

```bash
mix phx.new puppies
```

Change into the project directory:

```bash
cd puppies
```

And create a new Phoenix LiveView:

```bash
mix phx.gen.live Puppies Puppy puppies name:string breed:string color:string photo_url: string
```

This will generate the necessary files and migrations for our live view and model.

## Allowing Uploads

The very first thing we need to do is enable an upload on mount. Open the form component file at `lib/puppies_web/live/puppy_live/form_component.ex`. You will see an `update/2` function where we will enable file uploads. Add the following line to your callback:

```elixir
allow_upload(socket, :photo, accept: ~w(.png .jpeg .jpg .webp), max_entries: 1, auto_upload: true)
```

With the `update/2` callback ultimately looking like this:

```elixir
@impl true
def update(%{puppy: puppy} = assigns, socket) do
  changeset = Puppies.change_puppy(puppy)

  {:ok,
   socket
   |> allow_upload(:photo, accept: ~w(.png .jpeg .jpg .webp), max_entries: 1, auto_upload: true, external: &presign_entry/2)
   |> assign(assigns)
   |> assign(:form, to_form(changeset)}
end
```

This line enables file uploads for the `:photo` attribute, accepting only JPEG, WEBP, and PNG file formats and only one upload at a time via `max_entries: 1`. With `auto_upload: true`, a photo begins uploading as soon as a user selects it in our form. You can modify these options based on your requirements by looking at the [`allow_upload` function docs](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3).

**If you’re not using a LiveComponent, you can add this line to your `mount/2` callback.**

## Adding the Upload Markup

Next, we’re going to render the HTML elements in our form. This is where we will put our file input, a preview of our picture, and the upload percentage status. We'll also add drag-and-drop support for images.

To render the file upload form in our LiveView, we need to add the necessary HTML components. In the puppy form HTML, add all of these file upload goodies:

```elixir
<div>
  <%= hidden_input @form, :photo_url %> <%= error_tag @form, :photo_url %>

  <div phx-drop-target="{@uploads.photo.ref}">
    <.live_file_input upload={@uploads.photo} />
  </div>

  <div>
    <%= for {_ref, msg} <- @uploads.photo.errors do %>
    <h3><%= Phoenix.Naming.humanize(msg) %></h3>

    <% end %> <%= for entry <- @uploads.photo.entries do %> <.live_img_preview
    entry={entry} width="75" />
    <div class="py-5"><%= entry.progress %>%</div>
    <% end %>
  </div>
</div>
```

In this code, we render a file input with drag-and-drop support via the `phx-drop-target` form binding and [live_file_input](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#live_file_input/1) component provided by Phoenix LiveView. We also display a live file preview of the uploaded photo with `<.live_img_preview entry={entry} width="75" />`.

Live updates to the preview, progress, and errors will occur as the end-user interacts with the file input.

We render an image preview for each uploaded image stored in `@uploads.photo.entries`. The `@uploads` variable becomes available to us when we pipe into the `allow_upload/3` function in our form component (remember, you can do this in the `mount/2` callback for a LiveView if you aren’t using a LiveComponent).

`for {_ref, msg} <- @uploads.photo.errors` will display any errors, like uploading the wrong file type, to the user.

Finally, notice this:

```elixir
<%= hidden_input @form, :photo_url %>
<%= error_tag @form, :photo_url %>
```

These appear in the form because after we upload the file to Amazon S3, we’re going to persist the url of the picture to the database.

**Important: Your LiveView upload form must implement both `phx-submit` and `phx-change` callbacks, even if you’re not using them.**

The LiveView upload feature set will not work without these callbacks and subsequent `handle_event` callbacks. [Read more about this](https://hexdocs.pm/phoenix_live_view/uploads.html#render-reactive-elements).

## Configuring Amazon S3 to Phoenix LiveView

Before we can test our file upload feature, we need to set up our Amazon S3 bucket and configuration. Setting up your Amazon S3 bucket and configuring access is outside the scope of this guide, but [this video should help](https://www.youtube.com/watch?v=i2JXvgHsV9w).

## Consuming the File Upload on the Back-end

We’re going to crack open the [Direct to S3 guide](https://hexdocs.pm/phoenix_live_view/uploads-external.html#direct-to-s3) to bring all of this home.

In our `allow_upload` function call, add “external” support so that we can get it on Amazon S3. It should look like this:

```elixir
def update(%{puppy: puppy} = assigns, socket) do
  changeset = Puppies.change_puppy(puppy)

  {:ok,
   socket
   |> allow_upload(:photo, accept: ~w(.png .jpeg .jpg .webp), max_entries: 1, auto_upload: true, external: &presign_entry/2)
   |> assign(assigns)
   |> assign(:changeset, changeset)}
end

defp presign_entry(entry, %{assigns: %{uploads: uploads}} = socket) do
  {:ok, SimpleS3Upload.meta(entry, uploads), socket}
end
```

`external: &presign_entry/2` sends the file to our `presign_entry/2` function. This function dedicates work to a `SimpleS3Upload` module that generates a “presigned_url”. We need to upload files directly to Amazon S3 via our front-end safely, without exposing our Amazon S3 credentials.

So, we generate a pre-authenticated short-term URL on the back-end with our Amazon credentials. We then send this URL back to the front-end, where it can safely upload our file to Amazon S3, already completely safely authenticated.

You can find the [content of the SimpleS3Upload file here](https://gist.github.com/plicjo/5e5ced381f2b71d69d98b3e48885aacf#file-simple_s3_upload-ex). It’s 170+ lines of code purely dedicated to creating a pre-signed url for pre-authenticated file uploading.

Inside the `SimpleS3Upload` module, you’ll see references to the configuration you need to add.

```elixir
defp config do
  %{
    region: region(),
    access_key_id: Application.fetch_env!(:puppies, :access_key_id),
    secret_access_key: Application.fetch_env!(:puppies, :secret_access_key)
  }
end
```

In your app, you’ll need to specify your secret keys/credentials in your `config/config.exs`.

```elixir
config :puppies,
  access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
  secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"),
  bucket: System.fetch_env!("S3_BUCKET_NAME"),
  region: System.fetch_env!("AWS_REGION")
```

Now let's pull this through to the front-end!

## Uploading the File to S3 on the Front-end

We now need to upload the file from our front-end JavaScript in `assets/js/app.js`:

```elixir
let Uploaders = {}

Uploaders.S3 = function(entries, onViewError){
  entries.forEach(entry => {
    let formData = new FormData()
    let {url, fields} = entry.meta
    Object.entries(fields).forEach(([key, val]) => formData.append(key, val))
    formData.append("file", entry.file)
    let xhr = new XMLHttpRequest()
    onViewError(() => xhr.abort())
    xhr.onload = () => xhr.status === 204 ? entry.progress(100) : entry.error()
    xhr.onerror = () => entry.error()
    xhr.upload.addEventListener("progress", (event) => {
      if(event.lengthComputable){
        let percent = Math.round((event.loaded / event.total) * 100)
        if(percent < 100){ entry.progress(percent) }
      }
    })

    xhr.open("POST", url, true)
    xhr.send(formData)
  })
}

let liveSocket = new LiveSocket("/live", Socket, {
  uploaders: Uploaders,
  params: {_csrf_token: csrfToken}
})
```

This will upload each one of our files to Amazon S3 via AJAX.

Notice how we have an event listener for a “progress” event, always keeping the upload status available via `entry.progress(percent)`. Remember, we’re displaying the progress percentage in our HTML. And if there’s an error in the AJAX response, `entry.error()` has us covered. We render any file upload errors back to the user in the HTML, too.

The URL it’s uploading to is the pre-signed one that we generated on the back-end.

## Saving the Amazon URL to the Database

In our form, there's a form submit binding that looks like `phx_submit: "save"`, meaning we have a matching `handle_event` in our `LiveView/form_component`.

Make the following change:

```elixir
def handle_event("save", %{"puppy" => puppy_params}, socket) do
  puppy_params = put_photo_urls(socket, puppy_params)
  save_puppy(socket, socket.assigns.action, puppy_params)
end

defp put_photo_urls(socket, puppy) do
  uploaded_file_urls = consume_uploaded_entries(socket, :photo, fn _, entry ->
    {:ok, SimpleS3Upload.entry_url(entry)}
  end)

  %{puppy | "photo_url" => add_photo_url_to_params(List.first(uploaded_file_urls), puppy["photo_url"])}
end

defp add_photo_url_to_params(s3_url, photo_url) when is_nil(s3_url), do: photo_url
defp add_photo_url_to_params(s3_url, _photo_url), do: s3_url
```

We’re not adding the Amazon S3 photo URL to `puppy_params`. The `consume_uploaded_entries` function call is where this magic is happening.

`SimpleS3Upload.entry_url(entry)` is the dynamically generated URL where our image lives in our S3 bucket.

Notably, observe my pattern matching in the `add_photo_url_to_params` function. This guards us from overwriting the puppy with a blank URL if we don’t upload a file when updating the puppy’s attributes.

[Here's the final result](https://blog.appsignal.com/images/blog/2024-03/final-result-puppy.mp4).

## Wrapping Up

You now know how to upload from Phoenix LiveView directly to Amazon S3.

We began by enabling file uploads in our LiveView and adding the necessary components to our HTML template. We then created a pre-signed (pre-authenticated) URL to upload the file to with the `SimpleS3Upload` module. Next, we actually performed the file upload via AJAX calls by adding the uploader to our application’s JavaScript. Finally, we consumed the files we uploaded, persisting the URL to the database.

Have fun experimenting with this feature and enhancing your application!

**P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, [subscribe to our Elixir Alchemy newsletter and never miss a single post](/elixir-alchemy)!**