Direct File uploads with Phoenix Liveview and Cloudflare R2.

Sergio TapiaSergio Tapia
3 min read

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

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

0
Subscribe to my newsletter

Read articles from Sergio Tapia directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sergio Tapia
Sergio Tapia

I write open source software, check my Github! Ping me for collabs