elixir

Direct File Uploads to Amazon S3 with Phoenix LiveView

Joshua Plicque

Joshua Plicque on

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:

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.

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

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:

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

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!

Joshua Plicque

Joshua Plicque

Our guest author Joshua is a Phoenix LiveView and Elixir zealot, and founder of OrEquals, a software product company for notaries running iOS/macOS.

All articles by Joshua Plicque

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps