sergio

sergio

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

Most Liked

7rans

7rans

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) }?

Where Next?

Popular in Discussions Top

Nvim
Anybody knows a comprehensive comparison of Django and Phoenix, thanks for the help. Where are they similar? Where do they differ the m...
New
jeramyRR
This is an interesting article to read. Elixir’s performance, like usual, is excellent. However, it seems like the high CPU usage is co...
New
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
mmport80
I have put far too much effort into Dialyzer over the last year or so - and basically - I doubt it’s worth the effort. It’s not as easy ...
New
chuck
Let me start by stating an assumption: Phoenix is a great approach to building REST APIs. There are many reasons for this, but I will ass...
New
AstonJ
Please see the new poll here: Which code editor or IDE do you use? (Poll) (2022 Edition) It’s been a while since we first asked this, I...
208 31142 143
New
klo
Got a question about when to concat vs. prepending items to list then reversing to achieve appending. So i know lists boil down to [1 | ...
New
acrolink
How does the two languages compare when it comes to server side application development? Any experiences or ideas? Thank you.
New
pdgonzalez872
If this has been asked here before, please point me to where it was asked as I didn’t find it when I searched the forum. Maybe a mailing ...
New

Other popular topics Top

skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
vegabook
I’m brand new to Phoenix and I have stripped one of the demo applications to the bone. I just want to get an svg up on the screen. Here i...
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 39297 209
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 47930 226
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New

We're in Beta

About us Mission Statement