Uploading files to the cloud (s3 compatible)

Deankinyua

Deankinyua

Created 1 month ago

As profile images have become a standard in web applications, implementing this feature has become increasingly important. After a user fills in their user name, they are encouraged to share a picture of themselves as proof that they are who they claim to be. It might seem like a challenge that is hard to tackle, but LiveView has done a lot to reduce the probabilities of it going wrong. The browser only needs to send an HTTP POST request to the cloud service via an HTML form. The form needs to include the files we are uploading as well as some specific information that verifies that indeed we are allowed to perform this upload. Community developers have built packages that ease the process of uploading, retrieving and deleting files stored in the cloud. We are going to use the ExAws(used to make AWS requests) and ExAws.S3(used to specifically access the s3 service) packages.

You can install them by adding this to your mix.exs:

    #mix.exs
    {:ex_aws, "~> 2.0"},
    {:ex_aws_s3, "~> 2.0"},
    {:hackney, "~> 1.9"}, # or your preferred HTTP client
    {:sweet_xml, "~> 0.6.6"}, # optional dependency

Signature Version 4 Signing Process

This is the method that is used to authenticate and be authorized to upload files. You can use S3 or any other storage service that is S3 compatible. I will be using a local MinIO installation which you can find here.. To begin, ensure you obtain the following information from your cloud service provider and load them to your application like so:

#dev.exs 
config :ex_aws,
  region: {:system, "S3_REGION"},
  access_key_id: {:system, "S3_ACCESS_KEY_ID"},
  secret_access_key: {:system, "S3_SECRET_ACCESS_KEY"}

config :ex_aws, :s3,
  scheme: "http://",
  host: "localhost",
  port: 9000

Mime types

First of all it is imperative to know that we cannot upload just any MIME type. To configure and allow custom types add them to your config.exs like this:

#config.exs
config :mime, :types, %{
  "image/jpeg" => ["jpeg"],
  "image/png" => ["png"],
  "image/jpg" => ["jpg"]
  # "audio/x-ms-wma" => ["wma"]
}

Form heex template

It does not really matter whether you are uploading from a LiveView or a LiveComponent as long as your form implements the phx-change event binding. In this particular scenario we want our file to be automatically uploaded after a user chooses it so we do not have to include phx-submit.

  @impl Phoenix.LiveComponent
  def render(assigns) do
    ~H"""
    <section>
      <.form for={@form} phx-target={@myself} phx-change="check">
        <button
          type="button"
          class="border border-[#DFE3FA] league-spartan-semibold rounded-full px-6 py-2"
        >
          <fieldset>
            <.live_file_input type="file" upload={@uploads.photo} class="hidden pointer-events-none" />
          </fieldset>

          <.droptarget
            for={@uploads.photo.ref}
            on_click={JS.dispatch("click", to: "##{@uploads.photo.ref}", bubbles: false)}
            drop_target_ref={@uploads.photo.ref}
          />
        </button>

      </.form>
    </section>
    """
  end

The drop target will just look like a regular button when rendered:

  attr :on_click, JS, required: true
  attr :drop_target_ref, :string, required: true
  attr :for, :string, required: true

  @doc """
  Renders a drop target to upload files
  """

  def droptarget(assigns) do
    ~H"""
    <div phx-click={@on_click} phx-drop-target={@drop_target_ref} for={@for}>
      <Text.title>
        Upload a new photo
      </Text.title>
    </div>
    """
  end

Allowing Uploads

To tell LiveView that we are uploading files to an external service, we use the external option and we need to specify a two-arity function to generate metadata that the browser will use to upload files to S3. S3 requires a very specific set of form fields that are cryptographically signed using the AWS signed form API and we have the same thanks to Chris McCord. Just create a file simple_s3_upload.ex, and copy the contents from this modified gist.

  @impl Phoenix.LiveComponent
  def update(assigns, socket) do
    socket =
      socket
      |> assign(:uploaded_files, [])
      |> allow_upload(:photo,
        accept: ~w(.png .jpg .jpeg),
        max_entries: 1,
        id: "profile_image_file",
        max_file_size: 80_000_000,
        progress: &handle_progress/3,
        auto_upload: true,
        external: fn entry, socket ->
          SimpleS3Upload.presign_upload(entry, socket, "photo")
        end
      )

    form = to_form(%{})

    #  current_user = assigns
    {:ok,
     socket
     |> assign(assigns)
     |> assign(form: form)}
  end

Now that we have the generated metadata, we just need some JavaScript (specifically an AJAX request) that the browser will invoke to send the POST request to the S3 url. Create an uploaders.js function inside your js directory and paste in the following lines, then modify app.js:

import Uploaders from "./uploaders";
let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  uploaders: Uploaders,
});

Remember that we set auto_upload to true, so we can now automatically handle uploads through the 3-arity handle_progress function. It checks the entry’s progress and directly uploads the file to the cloud when done:

  defp handle_progress(:photo, entry, socket) do
    if entry.done? do
      _uploaded_file =
        consume_uploaded_entry(socket, entry, fn %{} = _meta ->
          filename = Map.get(entry, :uuid) <> "." <> SimpleS3Upload.ext(entry)
          original_filename = entry.client_name
          details = %{filename: filename, original_filename: original_filename}

          send(self(), {:update_profile_picture, details})

          {:ok, entry}
        end)

      {:noreply, socket}
    else
      {:noreply, socket}
    end
  end

I used Kernel.send to send the file’s data so that it can be stored on the database for easy retrieval but I won’t go into those details. That’s about it. I hope you enjoyed.