DEV Community

Cover image for Direct File Uploads to Amazon S3 with Phoenix LiveView
Joshua Plicque for AppSignal

Posted on • Originally published at blog.appsignal.com

Direct File Uploads to Amazon S3 with Phoenix LiveView

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, 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:

mix phx.new puppies
Enter fullscreen mode Exit fullscreen mode

Change into the project directory:

cd puppies
Enter fullscreen mode Exit fullscreen mode

And create a new Phoenix LiveView:

mix phx.gen.live Puppies Puppy puppies name:string breed:string color:string photo_url: string
Enter fullscreen mode Exit fullscreen mode

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:

allow_upload(socket, :photo, accept: ~w(.png .jpeg .jpg .webp), max_entries: 1, auto_upload: true)
Enter fullscreen mode Exit fullscreen mode

With the update/2 callback ultimately looking like this:

@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
Enter fullscreen mode Exit fullscreen mode

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.

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:

<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>
Enter fullscreen mode Exit fullscreen mode

In this code, we render a file input with drag-and-drop support via the phx-drop-target form binding and live_file_input 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:

<%= hidden_input @form, :photo_url %>
<%= error_tag @form, :photo_url %>
Enter fullscreen mode Exit fullscreen mode

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.

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.

Consuming the File Upload on the Back-end

We’re going to crack open the Direct to S3 guide 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:

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
Enter fullscreen mode Exit fullscreen mode

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. 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.

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
Enter fullscreen mode Exit fullscreen mode

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

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")
Enter fullscreen mode Exit fullscreen mode

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:

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}
})
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

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!

Top comments (0)