Direct File uploads with Phoenix Liveview and Cloudflare R2

Mirrored from my blog article: Direct File uploads with Phoenix Liveview and Cloudflare R2.


It’s been really hard to figure this out and I had to cobble together many sources to get it working. Enjoy!

image

We’re going to build a direct image upload to Cloudflare B2 from the browser. That way the file doesn’t travel to your servers and slow them down.

Create your bucket and set CORS

You’ll need to set your bucket to allow public access, and set some CORS rules to allow remote PUT requests.

And edit your CORS policy for that bucket:

[
  {
    "AllowedOrigins": [
      "http://localhost:4000",
      "https://app.onrender.com"
    ],
    "AllowedMethods": [
      "GET",
      "PUT",
      "POST"
    ],
    "AllowedHeaders": [
      "*"
    ],
    "ExposeHeaders": []
  }
]

Finally, you’ll need these environment variables so set them for your project locally.

export CLOUDFRONT_ACCOUNT_ID="xxx"
export CLOUDFRONT_BUCKET_NAME="xxx"
export CLOUDFRONT_R2_ACCESS_KEY_ID="xxx"
export CLOUDFRONT_R2_SECRET_ACCESS_KEY="xxx"

Install mix deps

{:ex_aws, "~> 2.5"},
{:ex_aws_s3, "~> 2.5"},
{:aws_signature, "~> 0.3.1"}

Create simple_s3_upload.ex helper module

This file was modified by someone else, many thanks to him!

defmodule MyApp.SimpleS3Upload do
  @moduledoc """
  Below is code from Chris McCord, modified for Cloudflare R2

  https://gist.github.com/chrismccord/37862f1f8b1f5148644b75d20d1cb073

  """
  @one_hour_seconds 3600

  @doc """
    Returns `{:ok, presigned_url}` where `presigned_url` is a url string

  """
  def presigned_put(config, opts) do
    key = Keyword.fetch!(opts, :key)
    expires_in = Keyword.get(opts, :expires_in, @one_hour_seconds)
    uri = "#{config.url}/#{URI.encode(key)}"

    url =
      :aws_signature.sign_v4_query_params(
        config.access_key_id,
        config.secret_access_key,
        config.region,
        "s3",
        :calendar.universal_time(),
        "PUT",
        uri,
        ttl: expires_in,
        uri_encode_path: false,
        body_digest: "UNSIGNED-PAYLOAD"
      )

    {:ok, url}
  end
end

Chin up boys, that was the hard part. Let’s push some files!


Allow_upload for your socket

We need to let liveview know we want some files.

def update(assigns, socket) do
  {:ok,
   socket
   |> assign(:uploaded_files, [])
   |> allow_upload(:profile_picture,
     accept: ~w(.jpg .jpeg .png),
     max_entries: 2,
     external: &presign_upload/2
   )
   |> assign_form(changeset)}
end

defp presign_upload(entry, socket) do
  filename = "#{entry.client_name}"
  key = "public/#{Nanoid.generate()}-#{filename}"

  config = %{
    region: "auto",
    access_key_id: System.get_env("CLOUDFRONT_R2_ACCESS_KEY_ID"),
    secret_access_key: System.get_env("CLOUDFRONT_R2_SECRET_ACCESS_KEY"),
    url:
      "https://#{System.get_env("CLOUDFRONT_BUCKET_NAME")}.#{System.get_env("CLOUDFRONT_ACCOUNT_ID")}.r2.cloudflarestorage.com"
  }

  {:ok, presigned_url} =
    MyApp.SimpleS3Upload.presigned_put(config,
      key: key,
      content_type: entry.client_type,
      max_file_size: socket.assigns.uploads[entry.upload_config].max_file_size
    )

  meta = %{
    uploader: "S3",
    key: key,
    url: presigned_url
  }

  {:ok, meta, socket}
end

The HTML for your form.

The dudes at Phoenix Framework core team built some awesome helpers for us. Don’t reinvent the wheel!

# Inside your <.simple_form...
<.live_file_input upload={@uploads.profile_picture} />
<%= for entry <- @uploads.profile_picture.entries do %>
  <article class="upload-entry">
    <figure>
      <.live_img_preview entry={entry} />
      <figcaption><%= entry.client_name %></figcaption>
    </figure>

    <%!-- entry.progress will update automatically for in-flight entries --%>
    <progress value={entry.progress} max="100"><%= entry.progress %>%</progress>

    <%!-- a regular click event whose handler will invoke Phoenix.LiveView.cancel_upload/3 --%>
    <button
      type="button"
      phx-click="cancel-upload"
      phx-value-ref={entry.ref}
      phx-target={@myself}
      aria-label="cancel"
    >
      &times;
    </button>

    <%!-- Phoenix.Component.upload_errors/2 returns a list of error atoms --%>
    <%= for err <- upload_errors(@uploads.profile_picture, entry) do %>
      <p class="alert alert-danger"><%= inspect(err) %></p>
    <% end %>
  </article>
<% end %>

Finally saving the file’s URL in your database schema.

Just consume_uploaded_entries and you will have access to the final URL.

defp save_company(socket, :new, company_params) do
  uploaded_files =
    consume_uploaded_entries(socket, :profile_picture, fn %{key: key}, _entry ->
      "https://pub-757575757575757575.r2.dev/#{key}"
    end)

  company_params = Map.put(company_params, "profile_picture_url", List.first(uploaded_files))
  Companies.create_company(company_params) 
end

And that it’s. Enjoy significantly cheaper storage and no egress fees with Cloudflare R2 storage.


If this helped you, follow me on Twitter/X!

https://twitter.com/yeyoparadox

8 Likes

Thanks for sharing! I know how grueling it can be to figure out stuff like this.

Btw, in update/2 where does changeset come from?

Aside, I’m not familiar with assign_form, is that a shortcut for %{ socket | form: to_form(changeset) }?

1 Like

I can’t seem to edit my OP but you’re right this is the function:

defp assign_form(socket, %Ecto.Changeset{} = changeset) do
  assign(socket, :form, to_form(changeset))
end

And changeset is coming from the assigns for this “_form” component.

changeset = MySchema.change_my_schema(assigns.my_schema)

One level above I’m rendering this form and passing in the schema we’re editing:

<.live_component
    action={:edit}
    module={FormComponent}
    my_schema={@my_schema}
    current_user={@current_user}
  />

thanks for posting these nuggets, gold

small thing Cloudfront is different from Cloudflare