S3 Direct Upload

Mix.install([
  {:phoenix_playground, "~> 0.1.5"},
  {:phoenix_live_view, "~> 1.0.0-rc", override: true},
  {:req_s3, "~> 0.2.3"}
])

Config

# Access system env in separate cell so Livebook can offer to use secrets.

Application.put_env(:demo, :s3_config,
  access_key_id: System.fetch_env!("LB_AWS_ACCESS_KEY_ID"),
  secret_access_key: System.fetch_env!("LB_AWS_SECRET_ACCESS_KEY"),
  endpoint_url: System.get_env("LB_AWS_ENDPOINT_URL_S3"),
  bucket: System.fetch_env!("LB_BUCKET_NAME")
)

Playground

# Direct to S3 uploads using Phoenix Playground and ReqS3
#
# Set BUCKET_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (and optionally AWS_ENDPOINT_URL_S3).
#
# Based on https://hexdocs.pm/phoenix_live_view/uploads-external.html#direct-to-s3

defmodule DemoLive do
  use Phoenix.LiveView

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:s3_config, Application.get_env(:demo, :s3_config))
     |> allow_upload(:photo,
       accept: ~w[.png .jpeg .jpg],
       max_entries: 1,
       auto_upload: true,
       external: &presign_upload/2
     )}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <form id="upload-form" phx-change="validate">
      <.live_file_input upload={@uploads.photo} />
    </form>

    <div phx-drop-target={@uploads.photo.ref} style="width: 20em; height: 10em; margin-top: 0.5em; padding: 1em; border: 1px dashed">
      <div :for={entry <- @uploads.photo.entries}>
        <.live_img_preview entry={entry} height="120" />
        <div><%= entry.progress %>%</div>
        <.link :if={entry.done?} href={presign_url(entry, @s3_config)}>Uploaded</.link>
      </div>
    </div>

    <script type="text/javascript">
      window.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)
        })
      }
    </script>

    <style type="text/css">
      body { padding: 1em; }
    </style>
    """
  end

  @impl true
  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end

  defp presign_upload(entry, socket) do
    s3_options = s3_options(entry, socket.assigns.s3_config)
    form = ReqS3.presign_form([content_type: entry.client_type] ++ s3_options)
    meta = %{uploader: "S3", key: s3_options[:key], url: form.url, fields: Map.new(form.fields)}
    {:ok, meta, socket}
  end

  defp presign_url(entry, config) do
    ReqS3.presign_url(s3_options(entry, config))
  end

  defp s3_options(entry, config) do
    [key: "uploads/#{entry.client_name}"] ++ config
  end
end

PhoenixPlayground.start(live: DemoLive)