Git Product home page Git Product logo

imgup's Introduction

image uploads

GitHub Workflow Status codecov.io contributions welcome HitCount

Effortlessly upload images to AWS S3 using Phoenix + LiveView.



Why? ๐Ÿคท

Building our app, we consider images an essential medium of communication.

"An Image is Worth 16x16 Words ..." ๐Ÿ˜‰

By adding support for interactive file uploads, we can leverage this feature and easily apply it any client app that wishes to upload their images to a reliable & secure place.

What? ๐Ÿ’ญ

This run-through will create a simple Phoenix LiveView web application that will allow you to choose/drag an image and upload it to your own AWS S3 bucket.

Who? ๐Ÿ‘ค

This tutorial is aimed at LiveView beginners that want to grasp how to do a simple file upload.

But it's also for us, for future reference on how to implement image (and file) upload on other applications.

If you are completely new to Phoenix and LiveView, we recommend you follow the LiveView Counter Tutorial: dwyl/phoenix-liveview-counter-tutorial

How? ๐Ÿ’ป

Prerequisites

This tutorial requires you have Elixir and Phoenix installed. If you you don't, please see how to install Elixir and Phoenix.

We assume you know the basics of Phoenix and have some knowledge of how it works. If you don't, we highly suggest you follow our other tutorials first. e.g: github.com/dwyl/phoenix-chat-example

In addition to this, some knowledge of AWS - what it is, what an S3 bucket is/does - is assumed.

Note: if you have questions or get stuck, please open an issue! /dwyl/imgup/issues

Run the App!

You can easily see the App in action on Fly.io: imgup.fly.dev

But if you want to run it on your localhost, follow these 3 easy steps:

1. Clone the Repo

Clone the latest code:

git clone [email protected]:dwyl/imgup.git && cd imgup

2. Get your AWS Keys and Export as Environment Variables

Create an .env file e.g:

vi .env

and add your credentials to it:

export AWS_ACCESS_KEY_ID='YOUR_KEY'
export AWS_SECRET_ACCESS_KEY='YOUR_KEY'
export AWS_REGION='eu-west-3'
export AWS_S3_BUCKET_ORIGINAL=imgup-original
export AWS_S3_BUCKET_COMPRESSED=imgup-compressed

In your terminal, run source .env to export the keys. We are assuming all of the resources created in your application will be on the same reason. This env variable will be used on two different occasions:

  • on our LiveView.
  • on our API (check api.md) with a package called ex_aws.

3. Download the Dependencies and Run the App!

Run the commands:

mix setup && mix s

Then open your web browser to: localhost:4000 and start uploading!

Build It! ๐Ÿ‘ฉโ€๐Ÿ’ป

0. Creating a fresh Phoenix project

Let's create a fresh Phoenix project. Run the following command in a given folder:

mix phx.new . --app app --no-dashboard --no-mailer

We're running mix phx.new to generate a new project without a dashboard and mailer (email) service, since we don't need those in our project.

After this, if you run mix phx.server to run your server, you should be able to see the following page.

We're ready to start implementing!

1. Adding LiveView capabilities to our project

As it stands, our project is not using LiveView. Let's fix this.

In lib/app_web/router.ex, change the scope "/" to the following.

  scope "/", AppWeb do
    pipe_through :browser

    live "/", ImgupLive
  end

Instead of using the PageController, we are going to be creating ImgupLive, a LiveView file.

Let's create our LiveView files. Inside lib/app_web, create a folder called live and create the following file imgup_live.ex.

defmodule AppWeb.ImgupLive do
  use AppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000)}
  end
end

This is a simple LiveView controller with the mount/3 function where we use the allow_upload/3 function, which is needed to allow file uploads in LiveView.

In the same live folder, create a file called imgup_live.html.heex and use the following code.

<.flash_group flash={@flash} />
<div class="px-4 py-10 flex justify-center sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="mx-auto max-w-xl w-[50vw] lg:mx-0">
    <form>
      <div class="space-y-12">
        <div class="border-b border-gray-900/10 pb-12">
          <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
          <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>

          <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">

            <div class="col-span-full">
              <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10">
                <div class="text-center">
                  <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                  </svg>
                  <div class="mt-4 flex text-sm leading-6 text-gray-600">
                    <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                      <span>Upload a file</span>
                      <input id="file-upload" name="file-upload" type="file" class="sr-only">
                    </label>
                    <p class="pl-1">or drag and drop</p>
                  </div>
                  <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <div class="mt-6 flex items-center justify-end gap-x-6">
        <button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
        <button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save</button>
      </div>
    </form>
  </div>
</div>

This is a simple HTML form that uses Tailwind CSS to enhance the presentation of the upload form. We'll also remove the unused header of the page layout, while we're at it.

Locate the file lib/app_web/components/layouts/app.html.heex and remove the <header> class. The file should only have the following code:

<main class="px-4 py-20 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

Now you can safely delete the lib/app_web/controllers folder, which is no longer used.

If you run mix phx.server, you should see the following screen:

This means we've successfully added LiveView and changed our view! We can now start implementing file uploads! ๐Ÿ—ณ๏ธ

If you want to see the changes made to the project, check b414b11.

2. Local file upload and preview

Let's add the ability for people to upload their images in our LiveView app and preview them before uploading to AWS S3.

Change lib/app_web/live/imgup_live.html.heex to the following piece of code:

<div class="px-4 py-10 flex justify-center sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="mx-auto max-w-xl w-[50vw] lg:mx-0">

    <div class="space-y-12">
      <div class="border-gray-900/10 pb-12">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>
        <p class="mt-1 text-sm leading-6 text-gray-600">You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.</p>

        <!-- File upload section -->
        <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">

          <div class="col-span-full">
            <div
              class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
              phx-drop-target={@uploads.image_list.ref}
            >
              <div class="text-center">
                <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                </svg>
                <div class="mt-4 flex text-sm leading-6 text-gray-600">
                  <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                    <form phx-change="validate" phx-submit="save">
                      <label class="cursor-pointer">
                        <.live_file_input upload={@uploads.image_list} class="hidden" />
                        Upload
                      </label>
                    </form>
                  </label>
                  <p class="pl-1">or drag and drop</p>
                </div>
                <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- File upload form -->
    <div class="mt-6 flex items-center justify-end gap-x-6">
      <button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
      <button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
        Upload
      </button>
    </div>

    <!-- Selected files preview section -->
    <div class="mt-12">
      <h2 class="text-base font-semibold leading-7 text-gray-900">Selected files</h2>
      <ul role="list" class="divide-y divide-gray-100">

        <%= for entry <- @uploads.image_list.entries do %>
          <li class="relative flex justify-between gap-x-6 py-5" id={"entry-#{entry.ref}"}>
            <div class="flex gap-x-4">
              <.live_img_preview entry={entry} class="h-auto w-12 flex-none bg-gray-50" />
              <div class="min-w-0 flex-auto">
                <p class="text-sm font-semibold leading-6 break-all text-gray-900">
                  <span class="absolute inset-x-0 -top-px bottom-0"></span>
                  <%= entry.client_name %>
                </p>
              </div>
            </div>
            <div
              class="flex items-center gap-x-4 cursor-pointer z-10"
              phx-click="remove-selected" phx-value-ref={entry.ref}
            >
              <svg fill="#cfcfcf" height="10" width="10" version="1.1" id={"close_pic-#{entry.ref}"} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
                viewBox="0 0 460.775 460.775" xml:space="preserve">
                <path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
                  c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
                  c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
                  c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
                  l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
                  c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
              </svg>
            </div>
          </li>
        <% end %>
      </ul>
    </div>
  </div>
</div>

We've added a few features:

  • used <.live_file_input/> for LiveView file upload. We've wrapped this component with an element that is annotated with the phx-drop-target attribute pointing to the DOM id of the file input. This allows people to click on the Upload text or drag and drop files into the container to upload an image.
  • iterated over @uploads.image_list.entries socket assign to list and preview the uploaded images. For this, we're using live_img_preview/1 to generate an image preview on the client.
  • the person using the app can remove entries that they've uploaded to the web app. We are adding an X icon that, once clicked, creates a remove-selected event, which passes the entry reference to the event handler. The latter makes use of the cancel_upload/3 function.

Because <.live_file_input/> is being used, we need to annotate its wrapping element with phx-submit and phx-change, as per https://hexdocs.pm/phoenix_live_view/uploads.html#render-reactive-elements.

Because we've added these bindings, we need to add the event handlers in lib/app_web/live/imgup_live.ex. Open it and update it to:

defmodule AppWeb.ImgupLive do
  use AppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000)}
  end

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

  @impl true
  def handle_event("remove-selected", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :image_list, ref)}
  end

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

For now, we're not validating and not doing anything on save. We just want to preview the images within the web app.

If you run mix phx.server, you should see the following screen.

3. File validation

Let's block the person to upload invalid files. Validation occurs automatically based on the conditions that we specified in allow_upload/3 in the mount/3 function.

Entries for files that do not match the allow_upload/3 spec will contain errors. Luckily, we can leverage upload_errors/2 helper function to render an error message pertaining to each entry.

By defining allow_upload/3, the object is defined in the socket assigns. We can find an array of errors pertaining to all of the entries/files that were selected inside the @uploads socket assigns under the :errors key.

With this, we can block the person to upload the files if:

  • there aren't any.
  • any of the files/entries have errors.

Let's implement this useful function to then use in our view. Open lib/app_web/live/imgup_live.ex and add the following functions.

  def are_files_uploadable?(image_list) do
    error_list = Map.get(image_list, :errors)
    Enum.empty?(error_list) and length(image_list.entries) > 0
  end

  def error_to_string(:too_large), do: "Too large"
  def error_to_string(:not_accepted), do: "You have selected an unacceptable file type"

Next, open lib/app_web/live/imgup_live.html.heex and change it to:

<div class="px-4 py-10 flex justify-center sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="mx-auto max-w-xl w-[50vw] lg:mx-0">

    <div class="space-y-12">
      <div class="border-gray-900/10 pb-12">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>
        <p class="mt-1 text-sm leading-6 text-gray-600">You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.</p>

        <!-- File upload section -->
        <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">

          <div class="col-span-full">
            <div
              class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
              phx-drop-target={@uploads.image_list.ref}
            >
              <div class="text-center">
                <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                </svg>
                <div class="mt-4 flex text-sm leading-6 text-gray-600">
                  <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                    <form phx-change="validate" phx-submit="save">
                      <label class="cursor-pointer">
                        <.live_file_input upload={@uploads.image_list} class="hidden" />
                        Upload
                      </label>
                    </form>
                  </label>
                  <p class="pl-1">or drag and drop</p>
                </div>
                <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- File upload form -->
    <div class="mt-6 flex items-center justify-end gap-x-6">
      <button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
      <button
        type="submit"
        class={"rounded-md
              #{if are_files_uploadable?(@uploads.image_list) do "bg-indigo-600" else "bg-indigo-200" end}
              px-3 py-2 text-sm font-semibold text-white shadow-sm
              #{if are_files_uploadable?(@uploads.image_list) do "hover:bg-indigo-500" end}
              focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"}
        disabled={!are_files_uploadable?(@uploads.image_list)}
      >
        Upload
      </button>
    </div>

    <!-- Selected files preview section -->
    <div class="mt-12">
      <h2 class="text-base font-semibold leading-7 text-gray-900">Selected files</h2>
      <ul role="list" class="divide-y divide-gray-100">

        <%= for entry <- @uploads.image_list.entries do %>

          <!-- Entry information -->
          <li class="relative flex justify-between gap-x-6 py-5" id={"entry-#{entry.ref}"}>
            <div class="flex gap-x-4">
              <.live_img_preview entry={entry} class="h-auto w-12 flex-none bg-gray-50" />
              <div class="min-w-0 flex-auto">
                <p class="text-sm font-semibold leading-6 break-all text-gray-900">
                  <span class="absolute inset-x-0 -top-px bottom-0"></span>
                  <%= entry.client_name %>
                </p>
              </div>
            </div>
            <div
              class="flex items-center gap-x-4 cursor-pointer z-10"
              phx-click="remove-selected" phx-value-ref={entry.ref}
            >
              <svg fill="#cfcfcf" height="10" width="10" version="1.1" id={"close_pic-#{entry.ref}"} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
                viewBox="0 0 460.775 460.775" xml:space="preserve">
                <path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
                  c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
                  c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
                  c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
                  l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
                  c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
              </svg>
            </div>
          </li>

          <!-- Entry errors -->
          <div>
            <%= for err <- upload_errors(@uploads.image_list, entry) do %>
              <div class="rounded-md bg-red-50 p-4 mb-2">
                <div class="flex">
                  <div class="flex-shrink-0">
                    <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
                    </svg>
                  </div>
                  <div class="ml-3">
                    <h3 class="text-sm font-medium text-red-800"><%= error_to_string(err) %></h3>
                  </div>
                </div>
              </div>
            <% end %>
          </div>
        <% end %>
      </ul>
    </div>
  </div>
</div>

We've made two modifications:

  • the "Upload" button now calls are_files_uploadable/0 to check if it should be disabled or not.
  • for each file, we are rendering an error using error_to_string/1 if it's invalid.

If you run mix phx.server and try to upload invalid files, you will see an error on the entry.

4. Uploading image to AWS S3 bucket

Now that the person is loading the images to our app, let's allow them to upload it to the cloud! โ˜๏ธ

The first thing we need to do is to add an anonymous function that will generate the needed metadata for each local file for external client uploaders - which is the case of AWS S3. We can set the 2-arity function in the :external parameter of allow_upload/3.

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000, external: &presign_upload/2)}
  end


  defp presign_upload(entry, socket) do
    uploads = socket.assigns.uploads
    bucket = "dwyl-imgup"
    key = "public/#{entry.client_name}"

    config = %{
      region: System.get_env("AWS_REGION"),
      access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
      secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY")
    }

    {:ok, fields} =
      SimpleS3Upload.sign_form_upload(config, bucket,
        key: key,
        content_type: entry.client_type,
        max_file_size: uploads[entry.upload_config].max_file_size,
        expires_in: :timer.hours(1)
      )

    meta = %{uploader: "S3", key: key, url: "https://#{bucket}.s3-#{config.region}.amazonaws.com", fields: fields}
    {:ok, meta, socket}
  end

This function will be called every time the person wants to *upload the selected files to AWS S3 bucket, i.e. presses the "Upload" button.

In the presign_upload/2 function, we are getting the uploads object from the socket assigns. This field uploads refers to the list of selected images prior to being uploaded.

In this function, we are setting up the multipart form data for the POST request that will be posted to AWS S3. We generate a pre-signed URL for the upload, and lastly we return the :ok result, with a payload of metadata for the client.

If you've noticed, the metadata must contain the :uploader key, specifying the name of the JavaScript client-side uploader. In our case, it's called S3. (we'll be implementing this in the section after the next one).

All of this is needed to correctly upload the images to our S3 bucket.

4.1 Adding multipart form data for images to be uploaded to the bucket

You might have noticed the previous function is using a module called SimpleS3Upload which signs the POST request multipart form data with the correct metadata.

For this, we are using the zero-dependency module in https://gist.github.com/chrismccord/37862f1f8b1f5148644b75d20d1cb073. Therefore, inside lib/app_web/, create a file called s3_upload.ex and post the following snippet of code.

defmodule SimpleS3Upload do
  @moduledoc """
  Dependency-free S3 Form Upload using HTTP POST sigv4

  https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
  """

  @doc """
  Signs a form upload.

  The configuration is a map which must contain the following keys:

    * `:region` - The AWS region, such as "us-east-1"
    * `:access_key_id` - The AWS access key id
    * `:secret_access_key` - The AWS secret access key


  Returns a map of form fields to be used on the client via the JavaScript `FormData` API.

  ## Options

    * `:key` - The required key of the object to be uploaded.
    * `:max_file_size` - The required maximum allowed file size in bytes.
    * `:content_type` - The required MIME type of the file to be uploaded.
    * `:expires_in` - The required expiration time in milliseconds from now
      before the signed upload expires.

  ## Examples

      config = %{
        region: "us-east-1",
        access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
        secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
      }

      {:ok, fields} =
        SimpleS3Upload.sign_form_upload(config, "my-bucket",
          key: "public/my-file-name",
          content_type: "image/png",
          max_file_size: 10_000,
          expires_in: :timer.hours(1)
        )

  """
  def sign_form_upload(config, bucket, opts) do
    key = Keyword.fetch!(opts, :key)
    max_file_size = Keyword.fetch!(opts, :max_file_size)
    content_type = Keyword.fetch!(opts, :content_type)
    expires_in = Keyword.fetch!(opts, :expires_in)

    expires_at = DateTime.add(DateTime.utc_now(), expires_in, :millisecond)
    amz_date = amz_date(expires_at)
    credential = credential(config, expires_at)

    encoded_policy =
      Base.encode64("""
      {
        "expiration": "#{DateTime.to_iso8601(expires_at)}",
        "conditions": [
          {"bucket":  "#{bucket}"},
          ["eq", "$key", "#{key}"],
          {"acl": "public-read"},
          ["eq", "$Content-Type", "#{content_type}"],
          ["content-length-range", 0, #{max_file_size}],
          {"x-amz-server-side-encryption": "AES256"},
          {"x-amz-credential": "#{credential}"},
          {"x-amz-algorithm": "AWS4-HMAC-SHA256"},
          {"x-amz-date": "#{amz_date}"}
        ]
      }
      """)

    fields = %{
      "key" => key,
      "acl" => "public-read",
      "content-type" => content_type,
      "x-amz-server-side-encryption" => "AES256",
      "x-amz-credential" => credential,
      "x-amz-algorithm" => "AWS4-HMAC-SHA256",
      "x-amz-date" => amz_date,
      "policy" => encoded_policy,
      "x-amz-signature" => signature(config, expires_at, encoded_policy)
    }

    {:ok, fields}
  end

  defp amz_date(time) do
    time
    |> NaiveDateTime.to_iso8601()
    |> String.split(".")
    |> List.first()
    |> String.replace("-", "")
    |> String.replace(":", "")
    |> Kernel.<>("Z")
  end

  defp credential(%{} = config, %DateTime{} = expires_at) do
    "#{config.access_key_id}/#{short_date(expires_at)}/#{config.region}/s3/aws4_request"
  end

  defp signature(config, %DateTime{} = expires_at, encoded_policy) do
    config
    |> signing_key(expires_at, "s3")
    |> sha256(encoded_policy)
    |> Base.encode16(case: :lower)
  end

  defp signing_key(%{} = config, %DateTime{} = expires_at, service) when service in ["s3"] do
    amz_date = short_date(expires_at)
    %{secret_access_key: secret, region: region} = config

    ("AWS4" <> secret)
    |> sha256(amz_date)
    |> sha256(region)
    |> sha256(service)
    |> sha256("aws4_request")
  end

  defp short_date(%DateTime{} = expires_at) do
    expires_at
    |> amz_date()
    |> String.slice(0..7)
  end

  defp sha256(secret, msg), do: :crypto.mac(:hmac, :sha256, secret, msg)
end

Awesome!

We now have the module correctly implemented within our app and actively being used in our presign_upload/2 function within our LiveView.

4.2 Implementing the S3 JavaScript client uploader

As previously mentioned, we need to implement the S3 uploader in our JavaScript client.

So, let's complete the flow! Open assets/js/app.js, and change the liveSocket variable with these changes:

let Uploaders = {}

Uploaders.S3 = function(entries, onViewError){
  entries.forEach(entry => {

    // Creating the form data and getting metadata
    let formData = new FormData()
    let {url, fields} = entry.meta

    // Getting each image entry and appending it to the form data
    Object.entries(fields).forEach(([key, val]) => formData.append(key, val))
    formData.append("file", entry.file)

    // Creating an AJAX request for each entry
    // using progress functions to report the upload events back to the LiveView.
    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)
  })
}


let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
    uploaders: Uploaders,
    params: {_csrf_token: csrfToken}
})

We are creating our S3 uploader, which creates the form data and appends the image files and necessary metadata. Additionally, it attaches progress handlers that communicates with the LiveView to get information on the progress of the image upload to the AWS S3 bucket.

We then use this uploader in the :uploaders field in the liveSocket variable declaration.

4.3 Creating the AWS S3 bucket

You might have noticed that in the presign_upload/2 we are using configurations from a S3 bucket. We've set the region, access_key_id and secret_access_key,

We don't have anything created in our AWS, so it's time to create the bucket so our images can have a place to sleep at night! ๐Ÿ›๏ธ

If you've never dealt with AWS before, we recommend you getting acquainted with S3 buckets. Find more information about AWS in https://github.com/dwyl/learn-amazon-web-services and about S3 in https://www.youtube.com/watch?v=77lMCiiMilo&ab_channel=AmazonWebServices.

Let's create an S3 bucket! Open https://s3.console.aws.amazon.com/s3/home and click on Create bucket.

You will be prompted with a wizard to create the bucket. Name the bucket whatever you want. In our case, we've named it dwyl-imgup, which is the same name that must be declared in the presign_upload/2 function in lib/app_web/live/imgup_live.ex. In the same section, choose a specific region. Similarly, this region is also declared in the presign_upload/2 function, so make sure they match.

Next, in Object Ownership, click on ACLs Enabled. This will allow anyone to read the images within our bucket.

After this, in Block Public Access settings for this bucket, un-toggle Block all public access. We need to do this because our app needs to be able to upload images to our file.

After this, click on Create bucket.

4.3.1 Changing the bucket permissions

Now that you've created the bucket, you'll see the page with all the buckets created. Click on the one you've just created

In the page, click on the Permissions tab.

Scroll down to Access control list (ACL) and click on the Edit button.

In the Everyone (public access) section, toggle the Read checkbox. This will make our images accessible.

At last, we need to change the CORS settings at the bottom of the page. We are going to open the bucket to the public, so anyone can check it. However, once deployed, you should change the AllowedOrigins to restrict what domains can view the bucket contents.

Paste the following and save.

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "POST",
            "GET",
            "PUT",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]

Warning

Again, don't forget to change the AllowedOrigins to the domain of your site. If you don't, all the contents of the bucket is publicly accessible to anyone. Unless you want anyone to see them, you should change this setting.

And those are all the changes we need! If you're lost with these, please visit https://stackoverflow.com/questions/71080354/getting-the-bucket-does-not-allow-acls-error. It details the steps you need to make to get your bucket ready!

4.4 Getting our credentials

Now that we have our fine bucket ๐Ÿชฃ properly created, we need the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for our presign_upload/2 function to work properly and correctly create the form metadata for our image files to be uploaded.

For this, visit https://us-east-1.console.aws.amazon.com/iamv2/home#/security_credentials?section=IAM_credentials. Alternatively, on the right side of the screen, click on your username and on Security Credentials.

Scroll down to Access Keys and, if you don't have any created, click on Create access key.

After this, click on the Application running outside AWS option.

Click on Next and give the keys a descriptive tag.

After this, click on Create access key. You will be shown the credentials, like so.

These keys are invalid. Don't ever share yours, they give access to your AWS resource.

Both of these credentials will need to be the env variables that presign_upload/2 will use. For this, simply create an .env file and add your credentials to it.

export AWS_ACCESS_KEY_ID='YOUR_KEY'
export AWS_SECRET_ACCESS_KEY='YOUR_KEY'

When running the app, in your terminal window, you need to run source .env to load these env variables so our app has access to them. Remember: if you close the terminal window, you'll have to run source .env again.

Don't ever push this .env file to a repo nor share it with anyone. They give people access to the AWS resource. Keep this in your computer/server and don't expose it to the world! If it does, you can always deactivate and delete the keys in the same page you've created them.

4.5 Changing view to upload files

All that's left is to make our view upload the files when the person clicks on the "Upload" button. Go to lib/app_web/live/imgup_live.html.heex and change it so it looks like so:

<div class="px-4 py-10 flex justify-center sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="mx-auto max-w-xl w-[50vw] lg:mx-0">

    <div class="space-y-12">
      <div class="border-gray-900/10 pb-12">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>
        <p class="mt-1 text-sm leading-6 text-gray-600">You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.</p>

        <!-- File upload section -->
        <form class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8" phx-change="validate" phx-submit="save">

          <div class="col-span-full">
            <div
              class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
              phx-drop-target={@uploads.image_list.ref}
            >
              <div class="text-center">
                <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                </svg>
                <div class="mt-4 flex text-sm leading-6 text-gray-600">
                  <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                    <div>
                      <label class="cursor-pointer">
                        <.live_file_input upload={@uploads.image_list} class="hidden" />
                        Upload
                      </label>
                    </div>
                  </label>
                  <p class="pl-1">or drag and drop</p>
                </div>
                <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
              </div>
            </div>
          </div>

          <div class="mt-6 flex items-center justify-end gap-x-6">
            <button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
            <button
              type="submit"
              class={"rounded-md
                    #{if are_files_uploadable?(@uploads.image_list) do "bg-indigo-600" else "bg-indigo-200" end}
                    px-3 py-2 text-sm font-semibold text-white shadow-sm
                    #{if are_files_uploadable?(@uploads.image_list) do "hover:bg-indigo-500" end}
                    focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"}
              disabled={!are_files_uploadable?(@uploads.image_list)}
            >
              Upload
            </button>
          </div>

        </form>
      </div>
    </div>

    <!-- Selected files preview section -->
    <div class="mt-12">
      <h2 class="text-base font-semibold leading-7 text-gray-900">Selected files</h2>
      <ul role="list" class="divide-y divide-gray-100">

        <%= for entry <- @uploads.image_list.entries do %>

          <!-- Entry information -->
          <li class="relative flex justify-between gap-x-6 py-5" id={"entry-#{entry.ref}"}>
            <div class="flex gap-x-4">
              <.live_img_preview entry={entry} class="h-auto w-12 flex-none bg-gray-50" />
              <div class="min-w-0 flex-auto">
                <p class="text-sm font-semibold leading-6 break-all text-gray-900">
                  <span class="absolute inset-x-0 -top-px bottom-0"></span>
                  <%= entry.client_name %>
                </p>
              </div>
            </div>
            <div
              class="flex items-center gap-x-4 cursor-pointer z-10"
              phx-click="remove-selected" phx-value-ref={entry.ref}
            >
              <svg fill="#cfcfcf" height="10" width="10" version="1.1" id={"close_pic-#{entry.ref}"} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
                viewBox="0 0 460.775 460.775" xml:space="preserve">
                <path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
                  c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
                  c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
                  c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
                  l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
                  c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
              </svg>
            </div>
          </li>

          <!-- Entry errors -->
          <div>
            <%= for err <- upload_errors(@uploads.image_list, entry) do %>
              <div class="rounded-md bg-red-50 p-4 mb-2">
                <div class="flex">
                  <div class="flex-shrink-0">
                    <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
                    </svg>
                  </div>
                  <div class="ml-3">
                    <h3 class="text-sm font-medium text-red-800"><%= error_to_string(err) %></h3>
                  </div>
                </div>
              </div>
            <% end %>
          </div>
        <% end %>
      </ul>
    </div>
  </div>
</div>

We've made an important change.

For live_file_input to work and upload the images when clicking the Upload button, the event created in phx-submit will only work if the Upload button (of type="submit") is within the <form> element.

Therefore, we've put the "Upload" button inside the form, which has the phx-submit="save" annotation. This means that, once the person wants to upload the images, the "save" event handler in the LiveView is invoked.

  def handle_event("save", _params, socket) do
    {:noreply, socket}
  end

It currently does nothing but we will process the uploaded files in a later section.

Now that we're uploading the images, we might have a scenario where the uploader client fails. Let's add the handler in lib/app_web/live/imgup_live.ex:

  def error_to_string(:external_client_failure), do: "Couldn't upload files to S3. Open an issue on Github and contact the repo owner."

The :external_client_failure is created when the uploader files. This is our way to handle it in case something happens.

And we're done!

If you run source .env and mix phx.server, select an image and click on "Upload", it should show in your bucket on AWS S3!

Awesome job! ๐ŸŽ‰

5. Feedback on progress of upload

We've got ourselves a working app! But, unfortunately, the person using it doesn't have any feedback when they successfully upload the image files ๐Ÿ˜”.

Let's fix this!

First, we ought to change the view. First, open lib/app_web/components/layouts/app.html.heex and change it.

<main class="px-4 sm:px-6 lg:px-8">
  <div class="mx-auto">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

We've basically made the app wrapper make use of the full width. This is just so everything looks better on mobile devices ๐Ÿ“ฑ.

Next, head over to lib/app_web/live/imgup_live.html.heex and change it to the following piece of code:

<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="flex flex-col justify-around md:flex-row">
    <div class="flex flex-col flex-1 md:mr-4">

      <!-- Drag and drop -->
      <div class="space-y-12">
        <div class="border-gray-900/10 pb-12">
          <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
          <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>
          <p class="mt-1 text-sm leading-6 text-gray-600">You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.</p>

          <!-- File upload section -->
          <form class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8" phx-change="validate" phx-submit="save" id="upload-form">

            <div class="col-span-full">
              <div
                class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
                phx-drop-target={@uploads.image_list.ref}
              >
                <div class="text-center">
                  <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                  </svg>
                  <div class="mt-4 flex text-sm leading-6 text-gray-600">
                    <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                      <div>
                        <label class="cursor-pointer">
                          <.live_file_input upload={@uploads.image_list} class="hidden" />
                          Upload
                        </label>
                      </div>
                    </label>
                    <p class="pl-1">or drag and drop</p>
                  </div>
                  <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
                </div>
              </div>
            </div>

            <div class="mt-6 flex items-center justify-end gap-x-6">
              <button
                id="submit_button"
                type="submit"
                class={"rounded-md
                      #{if are_files_uploadable?(@uploads.image_list) do "bg-indigo-600" else "bg-indigo-200" end}
                      px-3 py-2 text-sm font-semibold text-white shadow-sm
                      #{if are_files_uploadable?(@uploads.image_list) do "hover:bg-indigo-500" end}
                      focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"}
                disabled={!are_files_uploadable?(@uploads.image_list)}
              >
                Upload
              </button>
            </div>

          </form>
        </div>
      </div>

      <!-- Selected files preview section -->
      <div class="mt-12">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Selected files</h2>
        <ul role="list" class="divide-y divide-gray-100">

          <%= for entry <- @uploads.image_list.entries do %>

            <progress value={entry.progress} max="100" class="w-full h-1"> <%= entry.progress %>% </progress>

            <!-- Entry information -->
            <li class="pending-upload-item relative flex justify-between gap-x-6 py-5" id={"entry-#{entry.ref}"}>
              <div class="flex gap-x-4">
                <.live_img_preview entry={entry} class="h-auto w-12 flex-none bg-gray-50" />
                <div class="min-w-0 flex-auto">
                  <p class="text-sm font-semibold leading-6 break-all text-gray-900">
                    <span class="absolute inset-x-0 -top-px bottom-0"></span>
                    <%= entry.client_name %>
                  </p>
                </div>
              </div>
              <div
                class="flex items-center gap-x-4 cursor-pointer z-10"
                phx-click="remove-selected" phx-value-ref={entry.ref}
                id={"close_pic-#{entry.ref}"}
              >
                <svg fill="#cfcfcf" height="10" width="10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
                  viewBox="0 0 460.775 460.775" xml:space="preserve">
                  <path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
                    c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
                    c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
                    c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
                    l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
                    c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
                </svg>
              </div>
            </li>

            <!-- Entry errors -->
            <div>
              <%= for err <- upload_errors(@uploads.image_list, entry) do %>
                <div class="rounded-md bg-red-50 p-4 mb-2">
                  <div class="flex">
                    <div class="flex-shrink-0">
                      <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
                      </svg>
                    </div>
                    <div class="ml-3">
                      <h3 class="text-sm font-medium text-red-800"><%= error_to_string(err) %></h3>
                    </div>
                  </div>
                </div>
              <% end %>
            </div>
          <% end %>
        </ul>
      </div>

    </div>

    <div class='flex flex-col flex-1 mt-10 md:mt-0 md:ml-4'>
        <h2 class="text-base font-semibold leading-7 text-gray-900">Uploaded files</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">Your uploaded images will appear here below!</p>
        <p class="mt-1 text-sm leading-6 text-gray-600">These images are located in the S3 bucket! ๐Ÿชฃ</p>

        <ul role="list" class="divide-y divide-gray-100">
          <%= for file <- @uploaded_files do %>

            <!-- Entry information -->
            <li class="uploaded-item relative flex justify-between gap-x-6 py-5" id={"uploaded-#{file.key}"}>
              <div class="flex gap-x-4">
                <img class="block max-w-12 max-h-12 w-auto h-auto flex-none bg-gray-50" src={file.public_url}>
                <div class="min-w-0 flex-auto">
                  <a
                    class="text-sm font-semibold leading-6 break-all text-gray-900"
                    href={file.public_url}
                    target="_blank" rel="noopener noreferrer"
                  >
                    <%= file.public_url %>
                  </a>
                </div>
              </div>
            </li>

          <% end %>
        </ul>

    </div>
  </div>
</div>

Let's go over the changes we've made:

  • the app now has two responsive columns: one for selected image files and another one for the uploaded image files. The latter will have a list of the uploaded files, with the image preview and the public URL they're currently being stored - our S3 instance. The list of uploaded files pertain to the :uploaded_files socket assign we've defined on the mount/3 function in our LiveView lib/a--Web/live/imgup_live.ex file.
  • removed the "Cancel" button.
  • added a <progress> HTML element that uses the entry.progress value. This value is updated in real-time because of the uploader hook we've implemented in assets/js/app.js.

If you run mix phx.server, you should see the following screen.

If we click the "Upload" button, we can see the progress bar progress, indicating that the file is being uploaded.

If your image is small in size, this might not be discernable. Try to upload a 5 Mb file and you should see it more clearly.

However, nothing else changes. We need to consume our file entries to be displayed in the "Uploaded files" column we've just created!

For this, head over to lib/app_web/live/imgup_live.ex, locate the "save" event handler and change it to the following.

  def handle_event("save", _params, socket) do

    uploaded_files = consume_uploaded_entries(socket, :image_list, fn %{uploader: _} = meta, _entry ->
      public_url = meta.url <> "/#{meta.key}"
      meta = Map.put(meta, :public_url, public_url)
      {:ok, meta}
    end)

    {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))}
  end

We are using the consume_uploaded_entries/3 for this goal. This function consumes the selected file entries. For form submissions (which is our case), we are guaranteed that all entries have been "completed" before the submit event is invoked, meaning they are ready to be uploaded. Once file entries are consumed, they are removed from the selected files list.

In the third parameter, we pass a function that iterates over the files. We use this function to attach a public_url metadata to the file that is used in our view, more specifically the "Uploaded files" column.

Each list item of this "Uploaded files" column prints this public URL and previews the image.

You can see this behaviour if you run mix phx.server.

Awesome! ๐Ÿฅณ

Now the person has proper feedback to what is going on! Great job!

6. Unique file names

Currently, we are uploading the file images to the S3 bucket with the original file name. To have more control over our resources and avoid overriding images (when we upload images with the same name to our bucket, it gets overridden), we are going to assign a unique content ID to each file.

Luckily for us, this is fairly simple!

We first need to install the cid package. Open mix.exs and add the following line to the deps section.

 {:excid, "~> 0.1.0"}

And then run mix deps.get to install this new dependency.

Head over to config/config.exs and add these lines:

# https://github.com/dwyl/cid#how
config :excid, base: :base58

We are going to be using base58 as our default base because it yields less characters.

To change the name of the file, open lib/app_web/live/imgup_live.ex and locate the presign_upload/2. Change the key variable to the following:

    key = Cid.cid("#{DateTime.utc_now() |> DateTime.to_iso8601()}_#{entry.client_name}")

We are creating a CID from a string with the format currentdate_filename. This is the new filename.

If you run mix phx.server and upload a file, you will see that this new CID is present in the URL and in the uploaded file in the S3 bucket.

And here's the bucket!

Now we don't have conflicts between the files each person uploads!

7. Resizing/compressing files

We've set a hard limit on the image file size one person can upload. Because we're using cloud storage and doing so at a reduced scale, it's easy to dismiss any concerns about hosting data and their size. But if we think at scale, we ought to be careful when estimating our cloud storage budget. Those megabytes can stack up easily and quite fast.

So, it's good practice to implement image resizing/compression. Every time a person uploads an image, we want to save the original image in a bucket, compress it and save the compressed version in another bucket. The latter is what what will serve the client.

You may be wondering: why do we need two buckets? Besides decoupling resources, we want to mitigate the possibility of recursive event loops. For example, if we had everything in the same bucket, when a person uploads an original image, the lambda function would compress it and send it to the bucket. This new upload would trigger another compression, and so on.

This, of course, is not desirable and can become quite costly! This is why we'll create two buckets.

Now let's build our image compression pipeline following the architecture we've just detailed.

7.1 Installing AWS CLI and AWS SAM CLI

To make the setup and tear down of our pipeline easier, we'll be using AWS SAM. This will allow us to create serverless applications, combining multiple resources. Our SAM project will create the needed resources (S3 buckets and Lambda Function) and IAM roles necessary to execute image compression and read/write files to our S3 buckets.

With SAM, we can define and deploy our AWS resources with a easy-to-read YAML template.

To create a SAM project, you need to install the SAM CLI. But, before this, you need to fulfil the prerequisites named in https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/prerequisites.html. Essentially, you need:

  • a IAM user account.
  • an access key ID and secret access key.
  • AWS CLI.

Because you've already created your credentials to upload files to the buckets earlier, you probably only need to install the AWS CLI.

Therefore, follow https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html to install AWS CLI and then https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/prerequisites.html#prerequisites-configure-credentials to configure it with your AWS credentials.

After this, simply follow https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html to install the AWS SAM CLI.

After following these guides, you should be all set to create a new project!

7.2 Creating a new AWS SAM project

Now we're ready to create our AWS SAM project!

If you're lazy, you can just use the an_aws_sam_imgup-compressor folder and run the commands needed to deploy there. We'll deploy the pipeline in the next section, so feel free to skip this one if you want to skip creating the project folder, and go to 7.4 Deploying our AWS SAM project.

Open a terminal window and navigate to your project's directory. This process will create a folder within it. Type:

sam init

Step through the init options like so:

Which template source would you like to use?
	1 - AWS Quick Start Templates

Choose an AWS Quick Start application template
	1 - Hello World Example

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
	13 - nodejs14.x

What package type would you like to use?
	1 - Zip

Select your starter template
	1 - Hello World Example

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Would you like to enable monitoring using CloudWatch Application Insights?
For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]: N

Project name: your_project_name

Give your project name whatever you like. We gave ours imgup-compressor.

7.3 Changing the AWS SAM project files

Now it's time to define our SAM template! Navigate to the project directory that was just created and locate the template.yaml file. Change it to the following:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: DWYL-Imgup image compression pipeline

Parameters:
  UncompressedBucketName:
    Type: String
    Description: "Bucket for storing full resolution images"

  CompressedBucketName:
    Type: String
    Description: "Bucket for storing compressed images"

Resources:
  UncompressedBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref UncompressedBucketName

  CompressedBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref CompressedBucketName

  ImageCompressorLambda:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/index.handler
      Runtime: nodejs14.x
      MemorySize: 1536
      Timeout: 60
      Environment:
        Variables:
          UNCOMPRESSED_BUCKET: !Ref UncompressedBucketName
          COMPRESSED_BUCKET: !Ref CompressedBucketName
      Policies:
        - S3ReadPolicy:
           BucketName: !Ref UncompressedBucketName
        - S3WritePolicy:
            BucketName: !Ref CompressedBucketName
      Events:
        CompressImageEvent:
          Type: S3
          Properties:
            Bucket: !Ref UncompressedBucket
            Events: s3:ObjectCreated:*

Let's walk through the template:

  • the Parameters block will allow us to pass in some names for our S3 buckets when deploying our SAM template.
  • the Resources block has all the resources needed. In our case, we have the UncompressedBucket and CompressedBucket, which are both self-explanatory. Both buckets then have their respective bucket names set from the parameters we previously defined. The ImageCompressorLambda is the Lambda Function, which uses the Node.js runtime and points to src/index.handler location. Under the Policies section, we give the Lambda function the appropriate permissions to read data from the UncompressedBucket and write to CompressedBucket. And lastly, we configure the event trigger for the Lambda function. The event is fired any time an object is created in the UncompressedBucket.

7.3.1 Implementing the src/index.js handler

We are going to be using sharp to do the image compression and manipulation. Although we'll only shrink our images, you can do much more with this library, so we encourage you to peruse through the documentation.

To setup our Lambda function, we'll add sharp as as a dependency. According to https://sharp.pixelplumbing.com/install#aws-lambda, we need to run extra commands to make sure the binaries present within the node_modules are targeted for a Linux x64 platform. So, run the following commands in the project directory:

# windows users
rmdir /s /q node_modules/sharp
npm install --arch=x64 --platform=linux sharp

# mac users
rm -rf node_modules/sharp
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux sharp

This will remove sharp from the node_modules and install the dedicated Linux x64 dependency, which is best suited for Lambda Functions.

Now, we're ready to setup the Lambda Function logic! So, clear the src directory (you may delete the __tests__ directory as well), and add index.js within it. Then add the following code to src/index.js.

const AWS = require('aws-sdk');
const S3 = new AWS.S3();
const sharp = require('sharp');

exports.handler = async (event) => {

    // Collect the object key from the S3 event record
    const { key } = event.Records[0].s3.object;

    console.log({ triggerObject: key });

    // Collect the full resolution image from s3 using the object key
    const uncompressedImage = await S3.getObject({
        Bucket: process.env.UNCOMPRESSED_BUCKET,
        Key: key,
    }).promise();

    // Compress the image to a 200x200 avatar square as a buffer, without stretching
    const compressedImageBuffer = await sharp(uncompressedImage.Body)
    .resize({ 
        height: 200, 
        fit: 'contain'
    })
    .png()
    .toBuffer();

    // Upload the compressed image buffer to the Compressed Images bucket
    await S3.putObject({
        Bucket: process.env.COMPRESSED_BUCKET,
        Key: key,
        Body: compressedImageBuffer,
        ContentType: "image/png",
        ACL: 'public-read'
    }).promise();

    console.log(`Compressing ${key} complete!`)

}

In this code, we are:

  • extracting the image object key from the event that triggered the Lambda Function's execution.
  • using the aws sdk to download the image to our lambda function. Because we've defined the env variables in template.yaml, we can use them in our function. (e.g. process.env.UNCOMPRESSED_BUCKET).
  • with the downloaded image, we use sharp to resize it. We're resizing it to 200x200 and containing it so the aspect ratio remains intact. You can add more steps here if you want bigger compression, or just want to compress the image and not resize it.
  • with the response from the sharp object, we save it in the CompressedBucket with the same key as the original.

7.4 Deploying our AWS SAM project

Now we are ready to deploy the project! Let's run the following command first, to validate our template.yaml file looks good!

sam validate

You should see .../template.yaml is a valid SAM Template.

Now run:

sam build --use-container

You will need Docker for this step. Install it and make sure you are running it in your computer. This is necessary for this step to work, or else it will err.

Once that's complete, we can push our build (located in .aws-sam folder that was generated with the previous command) by running this command:

sam deploy --guided

Stepping through the guided deployment options, you will be given some options to specify the application stack name, region, the parameters we've defined and other questions. Here's how it might look like.

Make sure the name of the buckets are new. The deploy won't work if you are referencing pre-existing buckets.

Configuring SAM deploy
======================

	Looking for config file [samconfig.toml] :  Found
	Reading default arguments  :  Success

	Setting default arguments for 'sam deploy'
	=========================================
	Stack Name: imgup-compressor
	AWS Region: eu-west-3
	Parameter UncompressedBucketName: imgup-original
	Parameter CompressedBucketName: imgup-compressed
	#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
	Confirm changes before deploy [Y/n]: y
	#SAM needs permission to be able to create roles to connect to the resources in your template
	Allow SAM CLI IAM role creation [Y/n]: y
	#Preserves the state of previously provisioned resources when an operation fails
	Disable rollback [Y/n]:y
	Save arguments to configuration file [Y/n]:y
	SAM configuration file [samconfig.toml]:
	SAM configuration environment [default]:

	Looking for resources needed for deployment:

	Managed S3 bucket: YOUR_ARN
	A different default S3 bucket can be set in samconfig.toml and auto resolution of buckets turned off by setting resolve_s3=False

        Parameter "stack_name=imgup-compressor" in [default.deploy.parameters] is defined as a global parameter [default.global.parameters].
        This parameter will be only saved under [default.global.parameters] in SAMCONFIG.TOML_DIRECTORY

	Saved arguments to config file
	Running 'sam deploy' for future deployments will use the parameters saved above.
	The above parameters can be changed by modifying samconfig.toml
	Learn more about samconfig.toml syntax at
	https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html

	Uploading to imgup-compressor/d8c6387871515182264b3216514aa5ee  19584628 / 19584628  (100.00%)

	Deploying with following values
	===============================
	Stack name                   : imgup-compressor
	Region                       : eu-west-3
	Confirm changeset            : False
	Disable rollback             : True
	Deployment s3 bucket         : YOUR_S3_BUCKET_DEPLOYMENT_HERE
	Capabilities                 : ["CAPABILITY_IAM"]
	Parameter overrides          : {"UncompressedBucketName": "imgup-original", "CompressedBucketName": "imgup-compressed"}
	Signing Profiles             : {}

Initiating deployment
=====================

	Uploading to imgup-compressor/4c6644481fa7648c72204db9979bf585.template  1590 / 1590  (100.00%)


Waiting for changeset to be created..

CloudFormation stack changeset
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Operation                                                   LogicalResourceId                                           ResourceType                                                Replacement
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Add                                                       CompressedBucket                                            AWS::S3::Bucket                                             N/A
+ Add                                                       ImageCompressorLambdaCompressImageEventPermission           AWS::Lambda::Permission                                     N/A
+ Add                                                       ImageCompressorLambdaRole                                   AWS::IAM::Role                                              N/A
+ Add                                                       ImageCompressorLambda                                       AWS::Lambda::Function                                       N/A
+ Add                                                       UncompressedBucket                                          AWS::S3::Bucket                                             N/A
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Changeset created successfully on YOUR_ARN


2023-06-01 18:05:03 - Waiting for stack create/update to complete

CloudFormation events from stack operations (refresh every 5.0 seconds)
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                                              ResourceType                                                LogicalResourceId                                           ResourceStatusReason
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_IN_PROGRESS                                          AWS::IAM::Role                                              ImageCompressorLambdaRole                                   -
CREATE_IN_PROGRESS                                          AWS::S3::Bucket                                             CompressedBucket                                            -
CREATE_IN_PROGRESS                                          AWS::IAM::Role                                              ImageCompressorLambdaRole                                   Resource creation Initiated
CREATE_IN_PROGRESS                                          AWS::S3::Bucket                                             CompressedBucket                                            Resource creation Initiated
CREATE_COMPLETE                                             AWS::S3::Bucket                                             CompressedBucket                                            -
CREATE_COMPLETE                                             AWS::IAM::Role                                              ImageCompressorLambdaRole                                   -
CREATE_IN_PROGRESS                                          AWS::Lambda::Function                                       ImageCompressorLambda                                       -
CREATE_IN_PROGRESS                                          AWS::Lambda::Function                                       ImageCompressorLambda                                       Resource creation Initiated
CREATE_COMPLETE                                             AWS::Lambda::Function                                       ImageCompressorLambda                                       -
CREATE_IN_PROGRESS                                          AWS::Lambda::Permission                                     ImageCompressorLambdaCompressImageEventPermission           -
CREATE_IN_PROGRESS                                          AWS::Lambda::Permission                                     ImageCompressorLambdaCompressImageEventPermission           Resource creation Initiated
CREATE_COMPLETE                                             AWS::Lambda::Permission                                     ImageCompressorLambdaCompressImageEventPermission           -
CREATE_IN_PROGRESS                                          AWS::S3::Bucket                                             UncompressedBucket                                          -
CREATE_IN_PROGRESS                                          AWS::S3::Bucket                                             UncompressedBucket                                          Resource creation Initiated
CREATE_COMPLETE                                             AWS::S3::Bucket                                             UncompressedBucket                                          -
CREATE_COMPLETE                                             AWS::CloudFormation::Stack                                  imgup-compressor                                            -
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Successfully created/updated stack - imgup-compressor in eu-west-3

If everything has gone according to plan, you should be able to see this new deployment in your AWS console!

7.5 Testing the deployed SAM project in AWS Console

If you visit https://console.aws.amazon.com/cloudformation/home, you will see a CloudFormation Stack has been created.

From https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/stacks.html:

A stack is a collection of AWS resources that you can manage as a single unit. In other words, you can create, update, or delete a collection of resources by creating, updating, or deleting stacks. All the resources in a stack are defined by the stack's AWS CloudFormation template. A stack, for instance, can include all the resources required to run a web application, such as a web server, a database, and networking rules. If you no longer require that web application, you can simply delete the stack, and all of its related resources are deleted.

If you check your S3 buckets, you will see that the two buckets have been created as well. It is important that you follow the steps in 4.3.1 Changing the bucket permissions. We need the buckets to be public so they are accessible. Again, make sure the CORS definition points to the domain of the deployed web app. Or else anyone can read your bucket directly.

Additionally, a Lamdda Function should also have been created. Check https://console.aws.amazon.com/lambda/home and you should see it!

7.5.1 What if I want to make changes to the function?

If you want to make changes to the Lambda Function, you will have to rollback the deployment of the resources and re-build and re-deploy.

You can rollback by going to the CloudFormation Stack in https://console.aws.amazon.com/cloudformation/home with the name of the project we've created. Click on it and click on "Delete". This will initiate a rollback process that will delete the created resources.

Warning

Make sure the S3 buckets are empty before trying to rollback. If they aren't empty, the rollback process will fail.

7.6 Refactoring the Phoenix app to use image compression

Now that we've deployed our awesome image compression pipeline, we need to make changes to our LiveView application to make use of this newly deployed pipeline.

Open lib/app_web/live/imgup_live.ex and locate the presign_upload/2 function. Change it like so:

  defp presign_upload(entry, socket) do
    uploads = socket.assigns.uploads
    bucket_original = "imgup-original-test2"
    bucket_compressed = "imgup-compressed-test2"
    key = Cid.cid("#{DateTime.utc_now() |> DateTime.to_iso8601()}_#{entry.client_name}")

    config = %{
      region: "eu-west-3",
      access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
      secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY")
    }

    {:ok, fields} =
      SimpleS3Upload.sign_form_upload(config, bucket_original,
        key: key,
        content_type: entry.client_type,
        max_file_size: uploads[entry.upload_config].max_file_size,
        expires_in: :timer.hours(1)
      )

    meta = %{
      uploader: "S3",
      key: key,
      url: "https://#{bucket_original}.s3-#{config.region}.amazonaws.com",
      compressed_url: "https://#{bucket_compressed}.s3-#{config.region}.amazonaws.com",
      fields: fields}
    {:ok, meta, socket}
  end

We are now detailing bucket_original and bucket_compressed, pertaining to the bucket where original files are stored and compressed ones are stored, respectively. These buckets are used to create the public URLs, one for the original bucket and another one for the compressed one. This will be used to show to the person both URLs.

In the same file, we also need to change the "save" handler to contain the compressed_url as well.

  def handle_event("save", _params, socket) do

    uploaded_files = consume_uploaded_entries(socket, :image_list, fn %{uploader: _} = meta, _entry ->
      public_url = meta.url <> "/#{meta.key}"
      compressed_url = meta.compressed_url <> "/#{meta.key}"

      meta = Map.put(meta, :public_url, public_url)
      meta = Map.put(meta, :compressed_url, compressed_url)

      {:ok, meta}
    end)

    {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))}
  end

Now let's change our view to show both URLs. The uploaded files thumbnail will also be changed to be sourced from the bucket with compressed images. Open lib/app_web/live/imgup_live.html.heex and change it to the following:

<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="flex flex-col justify-around md:flex-row">
    <div class="flex flex-col flex-1 md:mr-4">

      <!-- Drag and drop -->
      <div class="space-y-12">
        <div class="border-gray-900/10 pb-12">
          <h2 class="text-base font-semibold leading-7 text-gray-900">Image Upload</h2>
          <p class="mt-1 text-sm leading-6 text-gray-600">Drag your images and they'll be uploaded to the cloud! โ˜๏ธ</p>
          <p class="mt-1 text-sm leading-6 text-gray-600">You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.</p>

          <!-- File upload section -->
          <form class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8" phx-change="validate" phx-submit="save" id="upload-form">

            <div class="col-span-full">
              <div
                class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
                phx-drop-target={@uploads.image_list.ref}
              >
                <div class="text-center">
                  <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
                  </svg>
                  <div class="mt-4 flex text-sm leading-6 text-gray-600">
                    <label for="file-upload" class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
                      <div>
                        <label class="cursor-pointer">
                          <.live_file_input upload={@uploads.image_list} class="hidden" />
                          Upload
                        </label>
                      </div>
                    </label>
                    <p class="pl-1">or drag and drop</p>
                  </div>
                  <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
                </div>
              </div>
            </div>

            <div class="mt-6 flex items-center justify-end gap-x-6">
              <button
                id="submit_button"
                type="submit"
                class={"rounded-md
                      #{if are_files_uploadable?(@uploads.image_list) do "bg-indigo-600" else "bg-indigo-200" end}
                      px-3 py-2 text-sm font-semibold text-white shadow-sm
                      #{if are_files_uploadable?(@uploads.image_list) do "hover:bg-indigo-500" end}
                      focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"}
                disabled={!are_files_uploadable?(@uploads.image_list)}
              >
                Upload
              </button>
            </div>

          </form>
        </div>
      </div>

      <!-- Selected files preview section -->
      <div class="mt-12">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Selected files</h2>
        <ul role="list" class="divide-y divide-gray-100">

          <%= for entry <- @uploads.image_list.entries do %>

            <progress value={entry.progress} max="100" class="w-full h-1"> <%= entry.progress %>% </progress>

            <!-- Entry information -->
            <li class="pending-upload-item relative flex justify-between gap-x-6 py-5" id={"entry-#{entry.ref}"}>
              <div class="flex gap-x-4">
                <.live_img_preview entry={entry} class="h-auto w-12 flex-none bg-gray-50" />
                <div class="min-w-0 flex-auto">
                  <p class="text-sm font-semibold leading-6 break-all text-gray-900">
                    <span class="absolute inset-x-0 -top-px bottom-0"></span>
                    <%= entry.client_name %>
                  </p>
                </div>
              </div>
              <div
                class="flex items-center gap-x-4 cursor-pointer z-10"
                phx-click="remove-selected" phx-value-ref={entry.ref}
                id={"close_pic-#{entry.ref}"}
              >
                <svg fill="#cfcfcf" height="10" width="10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
                  viewBox="0 0 460.775 460.775" xml:space="preserve">
                  <path d="M285.08,230.397L456.218,59.27c6.076-6.077,6.076-15.911,0-21.986L423.511,4.565c-2.913-2.911-6.866-4.55-10.992-4.55
                    c-4.127,0-8.08,1.639-10.993,4.55l-171.138,171.14L59.25,4.565c-2.913-2.911-6.866-4.55-10.993-4.55
                    c-4.126,0-8.08,1.639-10.992,4.55L4.558,37.284c-6.077,6.075-6.077,15.909,0,21.986l171.138,171.128L4.575,401.505
                    c-6.074,6.077-6.074,15.911,0,21.986l32.709,32.719c2.911,2.911,6.865,4.55,10.992,4.55c4.127,0,8.08-1.639,10.994-4.55
                    l171.117-171.12l171.118,171.12c2.913,2.911,6.866,4.55,10.993,4.55c4.128,0,8.081-1.639,10.992-4.55l32.709-32.719
                    c6.074-6.075,6.074-15.909,0-21.986L285.08,230.397z"/>
                </svg>
              </div>
            </li>

            <!-- Entry errors -->
            <div>
              <%= for err <- upload_errors(@uploads.image_list, entry) do %>
                <div class="rounded-md bg-red-50 p-4 mb-2">
                  <div class="flex">
                    <div class="flex-shrink-0">
                      <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
                      </svg>
                    </div>
                    <div class="ml-3">
                      <h3 class="text-sm font-medium text-red-800"><%= error_to_string(err) %></h3>
                    </div>
                  </div>
                </div>
              <% end %>
            </div>
          <% end %>
        </ul>
      </div>

    </div>

    <div class='flex flex-col flex-1 mt-10 md:mt-0 md:ml-4'>
        <h2 class="text-base font-semibold leading-7 text-gray-900">Uploaded files</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">Your uploaded images will appear here below!</p>
        <p class="mt-1 text-sm leading-6 text-gray-600">These images are located in the S3 bucket! ๐Ÿชฃ</p>

        <ul role="list" class="divide-y divide-gray-100">
          <%= for file <- @uploaded_files do %>

            <!-- Entry information -->
            <li class="uploaded-item relative flex justify-between gap-x-6 py-5" id={"uploaded-#{file.key}"}>
              <div class="flex gap-x-4">
                <!--
                Try to load the compressed image from S3. This is because the compression might take some time, so we retry until it's available
                See https://stackoverflow.com/questions/19673254/js-jquery-retry-img-load-after-1-second.
                -->
                <img class="block max-w-12 max-h-12 w-auto h-auto flex-none bg-gray-50" src={file.compressed_url} onerror="imgError(this);" >
                <div class="min-w-0 flex-auto">
                  <p>
                    <span class="text-sm font-semibold leading-6 break-all text-gray-900">Original URL:</span>
                    <a
                      class="text-sm leading-6 break-all underline text-indigo-600"
                      href={file.public_url}
                      target="_blank" rel="noopener noreferrer"
                    >
                      <%= file.public_url %>
                    </a>
                  </p>
                  <p>
                    <span class="text-sm font-semibold leading-6 break-all text-gray-900">Compressed URL:</span>
                    <a
                      class="text-sm leading-6 break-all underline text-indigo-600"
                      href={file.compressed_url}
                      target="_blank" rel="noopener noreferrer"
                    >
                      <%= file.compressed_url %>
                    </a>
                  </p>
                </div>
              </div>
            </li>

          <% end %>
        </ul>
    </div>

  </div>
</div>

Now the uploaded image's item shows both URLs. Additionally, we have defined an onerror callback on the thumbnail. This is mainly because the compressed image might not be available right off the bat (it's still being compressed), so we define imgError function to retry loading the image every second.

To define imgError, open lib/app_web/components/layouts/root.html.heex and add the function to the script.

<!DOCTYPE html>
<html lang="en" style="scrollbar-gutter: stable;">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" ยท Phoenix Framework">
      <%= assigns[:page_title] || "App" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-white antialiased">
    <%= @inner_content %>

    <script>
      function imgError(image) {
        image.onerror = null;
        setTimeout(function (){
            image.src += '?' + +new Date;
        }, 1000);
      }
    </script>
  </body>
</html>

7.7 Run it!

Now let's run it! If you run mix phx.server and upload a file, you'll see the following screen.

Both buckets now have the file with the same key and are publicly accessible!

Awesome job! You've just added image compression to your web app! ๐ŸŽ‰

8. A note when deploying online

If you want people to access your bucket publicly, it is wise to not let it be abused (it can be quite costly for you!).

We recommend deleting files after X days so you don't pay high amounts of storage.

For this, please follow https://repost.aws/knowledge-center/s3-empty-bucket-lifecycle-rule to set lifecycle rules on both of your buckets. This will delete all the files of the bucket every X days.

9. Uploading files without Javascript

Note:

This section assumes you've implemented the API, as described in api.md. We are going to be using an upload/1 function to directly upload a given file to an S3 bucket in our LiveView server.

Give the document a read first so you're up to par! ๐Ÿ˜„

As you might have noticed, we are using Javascript (in assets/js/app.js) to upload the file to a given Uploader (in our case, an S3 bucket). Although doing this in the client code is handy, it's useful to showcase a completely server-sided option, in which the file is uploaded in our LiveView Elixir server.

For this, we are going to be a clientless file upload page (to demonstrate this other scenario). This page will be similar to the previously developed LiveView page, albeit with some differences.

Here is the flow of what the person using the page will expect to upload a file.

  • choose a file to input.
  • upon successful selection, the image will be automatically uploaded locally in the server.
  • to upload the file to the S3 bucket, the person will have to manually click the Upload button to upload the locally-saved file in the server to the bucket.
  • after a successful upload, the person will be shown both the original and compressed URLs, just like before!

This is our flow. So let's add our tests to represent this!

In test/app_web/live, create a file called imgup_clientless_live_test.exs.

defmodule AppWeb.ImgupClientlessLiveTest do
  use AppWeb.ConnCase
  import Phoenix.LiveViewTest

  test "connected mount", %{conn: conn} do
    conn = get(conn, "/liveview_clientless")
    assert html_response(conn, 200) =~ "(without file upload from client-side code)"

    {:ok, _view, _html} = live(conn)
  end

  import AppWeb.UploadSupport

  test "uploading a file", %{conn: conn} do
    {:ok, lv, html} = live(conn, ~p"/liveview_clientless")
    assert html =~ "Image Upload"

    # Get file and add it to the form
    file =
      [:code.priv_dir(:app), "static", "images", "phoenix.png"]
      |> Path.join()
      |> build_upload("image/png")

    image = file_input(lv, "#upload-form", :image_list, [file])

    # Should show an uploaded local file
    assert render_upload(image, file.name)
           |> Floki.parse_document!()
           |> Floki.find(".uploaded-local-item")
           |> length() == 1

    # Click on the upload button
    lv |> element(".submit_button") |> render_click()

    # Should show an uploaded S3 file
    assert lv
           |> render()
           |> Floki.parse_document!()
           |> Floki.find(".uploaded-s3-item")
           |> length() == 1
  end

  test "uploading an image file with invalid extension fails and should show error", %{conn: conn} do
    {:ok, lv, html} = live(conn, ~p"/liveview_clientless")
    assert html =~ "Image Upload"

    # Get empty file and add it to the form
    file =
      [:code.priv_dir(:app), "static", "images", "phoenix.xyz"]
      |> Path.join()
      |> build_upload("image/invalid")

    image = file_input(lv, "#upload-form", :image_list, [file])

    # Upload locally
    assert render_upload(image, file.name)

    # Click on the upload button
    lv |> element(".submit_button") |> render_click()

    # Should show an error
    assert lv |> render() =~ "invalid_extension"
  end

  test "validate function should reply `no_reply`", %{conn: conn} do
    assert AppWeb.ImgupNoClientLive.handle_event("validate", %{}, conn) == {:noreply, conn}
  end
end

As you can see, we're simply testing a success scenario (when a file is uploaded successfully to S3) and another if the upload (for whatever reason) fails when uploading a file. In the latter, an error should be shown.

Now that we've our tests, let's start implementing!

9.1 Creating a new LiveView

Let's create a new file called imgup_no_client_live.ex inside lib/app_web/controllers/live. Use the following code:

defmodule AppWeb.ImgupNoClientLive do
  use AppWeb, :live_view

  @upload_dir Application.app_dir(:app, ["priv", "static", "image_uploads"])

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files_locally, [])
     |> assign(:uploaded_files_to_S3, [])
     |> allow_upload(:image_list,
       accept: ~w(image/*),
       max_entries: 6,
       chunk_size: 64_000,
       auto_upload: true,
       max_file_size: 5_000_000,
       progress: &handle_progress/3
       # Do not define presign_upload. This will create a local photo in /vars
     )}
  end

  # With `auto_upload: true`, we can consume files here
  defp handle_progress(:image_list, entry, socket) do
    if entry.done? do
      uploaded_file =
        consume_uploaded_entry(socket, entry, fn %{path: path} ->
          dest = Path.join(@upload_dir, entry.client_name)

          # Copying the file from temporary folder to static folder
          File.mkdir_p(@upload_dir)
          File.cp!(path, dest)

          # Adding properties to the entry.
          # It should look like %{image_url: url, url_path: path, errors: []}
          entry =
            entry
            |> Map.put(
              :image_url,
              AppWeb.Endpoint.url() <>
                AppWeb.Endpoint.static_path("/image_uploads/#{entry.client_name}")
            )
            |> Map.put(
              :url_path,
              AppWeb.Endpoint.static_path("/image_uploads/#{entry.client_name}")
            )
            |> Map.put(
              :errors,
              []
            )

          {:ok, entry}
        end)

      {:noreply, update(socket, :uploaded_files_locally, &(&1 ++ [uploaded_file]))}
    else
      {:noreply, socket}
    end
  end

  # Event handlers -------

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

  @impl true
  def handle_event("upload_to_s3", params, socket) do
    # Get file element from the local files array
    file_element =
      Enum.find(socket.assigns.uploaded_files_locally, fn %{uuid: uuid} ->
        uuid == Map.get(params, "uuid")
      end)

    # Create file object to upload
    file = %{
      path: @upload_dir <> "/" <> Map.get(file_element, :client_name),
      content_type: file_element.client_type,
      filename: file_element.client_name
    }

    # Upload file
    case App.Upload.upload(file) do
      # If the upload succeeds...
      {:ok, body} ->
        # We add the `uuid` to the object to display on the view template.
        body = Map.put(body, :uuid, file_element.uuid)

        # Delete the file locally
        File.rm!(file.path)

        # Update the socket accordingly
        updated_local_array = List.delete(socket.assigns.uploaded_files_locally, file_element)

        socket = update(socket, :uploaded_files_to_S3, &(&1 ++ [body]))
        socket = assign(socket, :uploaded_files_locally, updated_local_array)

        {:noreply, socket}

      # If the upload fails...
      {:error, reason} ->

        # Update the failed local file element to show an error message
        index = Enum.find_index(socket.assigns.uploaded_files_locally, &(&1 == file_element))
        updated_file_element = Map.put(file_element, :errors, ["#{reason}"])
        updated_local_array = List.replace_at(socket.assigns.uploaded_files_locally, index, updated_file_element)

        {:noreply, assign(socket, :uploaded_files_locally, updated_local_array)}
    end
  end
end

Let's break down what we've just implemented.

  • in mount/3, we've used allow_upload/3 with the auto_upload setting turned on. This instructs the client to upload the file automatically on file selection instead of waiting for form submits. So, whenever the person uploads a file, it will be uploaded locally automatically. Do note we are *not using presign_upload. This is because we don't want to upload the files externally yet. So this option needs to not be defined in order to upload the files locally.

  • in mount/3, we are also defining two arrays. uploaded_files_locally tracks the files uploaded locally by the person. uploaded_files_to_S3 tracks the files uploaded to the S3 bucket.

  • handle_progress/3 is automatically invoked after a file is selected by the person - this is because auto_upload is set to true. We consume_uploaded_entry to get the file locally and so LiveView knows it's been uploaded. Inside the callback of this function, we create the file locally and create the object to be added to the uploaded_files_locally array in the socket assigns. Each object follows the structure %{image_url: url, url_path: path, errors: []}. The files are being saved inside priv/static/image_uploads.

  • handle_event("upload_to_s3, params, socket) will be invoked when the person clicks on the Upload button to upload a given locally uploaded file. It will call the App.Upload.upload/1 function implemented in api.md. If the file is correctly uploaded, it is added to the uploaded_files_to_s3 socket assigns. If not, an error is added to the file object inside the uploaded_files_locally socket assigns so it can be shown to the person.

Now that we have our LiveView, we ought to add a view. Let's do that!

9.2 Adding our view

Inside lib/app_web/controllers/live, create a file called imgup_no_client_live.html.heex.

<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
  <div class="flex flex-col justify-around md:flex-row">
    <div class="flex flex-col flex-1 md:mr-4">
      <!-- Drag and drop -->
      <div class="space-y-12">
        <div class="border-gray-900/10 pb-12">
          <h2 class="text-base font-semibold leading-7 text-gray-900">
            Image Upload <b>(without file upload from client-side code)</b>
          </h2>
          <p class="mt-1 text-sm leading-6 text-gray-400">
            The files uploaded in this page are not routed from the client. Meaning all file uploads are made in the LiveView code.
          </p>
          <p class="mt-1 text-sm leading-6 text-gray-600">
            Drag your images and they'll be uploaded to the cloud! โ˜๏ธ
          </p>
          <p class="mt-1 text-sm leading-6 text-gray-600">
            You may add up to <%= @uploads.image_list.max_entries %> exhibits at a time.
          </p>
          <!-- File upload section -->
          <form
            class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8"
            phx-change="validate"
            phx-submit="save"
            id="upload-form"
          >
            <div class="col-span-full">
              <div
                class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 px-6 py-10"
                phx-drop-target={@uploads.image_list.ref}
              >
                <div class="text-center">
                  <svg
                    class="mx-auto h-12 w-12 text-gray-300"
                    viewBox="0 0 24 24"
                    fill="currentColor"
                    aria-hidden="true"
                  >
                    <path
                      fill-rule="evenodd"
                      d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z"
                      clip-rule="evenodd"
                    />
                  </svg>
                  <div class="mt-4 flex text-sm leading-6 text-gray-600">
                    <label
                      for="file-upload"
                      class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
                    >
                      <div>
                        <label class="cursor-pointer">
                          <.live_file_input upload={@uploads.image_list} class="hidden" /> Upload
                        </label>
                      </div>
                    </label>
                    <p class="pl-1">or drag and drop</p>
                  </div>
                  <p class="text-xs leading-5 text-gray-600">PNG, JPG, GIF up to 10MB</p>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

    <div class="flex flex-col flex-1 mt-10 md:mt-0 md:ml-4">
      <div>
        <h2 class="text-base font-semibold leading-7 text-gray-900">Uploaded files locally</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">
          Before uploading the images to S3, the files will be available locally.
        </p>
        <p class="mt-1 text-sm leading-6 text-gray-600">
          So these are the images that can be found locally!
        </p>

        <p class={"
            #{if length(@uploaded_files_locally) == 0 do "block" else "hidden" end}
            text-xs leading-7 text-gray-400 text-center my-10"}>
          No files uploaded.
        </p>

        <ul role="list" class="divide-y divide-gray-100">
          <%= for file <- @uploaded_files_locally do %>
            <!-- Entry information -->
            <li
              class="uploaded-local-item relative flex justify-between gap-x-6 py-5"
              id={"uploaded-locally-#{file.uuid}"}
            >
              <div class="flex gap-x-4">
                <!--
                    Try to load the compressed image from S3. This is because the compression might take some time, so we retry until it's available
                    See https://stackoverflow.com/questions/19673254/js-jquery-retry-img-load-after-1-second.
                    -->
                <img
                  class="block max-w-12 max-h-12 w-auto h-auto flex-none bg-gray-50"
                  src={file.image_url}
                />
                <div class="min-w-0 flex-auto">
                  <p>
                    <span class="text-sm font-semibold leading-6 break-all text-gray-900">
                      URL path:
                    </span>
                    <a
                      class="text-sm leading-6 break-all underline text-indigo-600"
                      href={file.image_url}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <%= file.url_path %>
                    </a>
                  </p>
                </div>
              </div>
              <div class="flex items-center justify-end gap-x-6">
                <button
                  id={"#submit_button-#{file.uuid}"}
                  phx-click={JS.push("upload_to_s3", value: %{uuid: file.uuid})}
                  class="
                            submit_button
                            rounded-md
                            bg-indigo-600
                            px-3 py-2 text-sm font-semibold text-white shadow-sm
                            hover:bg-indigo-500
                            focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
                >
                  Upload
                </button>
              </div>
            </li>
            <!-- Entry errors -->
            <div>
              <%= for err <- file.errors do %>
                <div class="rounded-md bg-red-50 p-4 mb-2">
                  <div class="flex">
                    <div class="flex-shrink-0">
                      <svg
                        class="h-5 w-5 text-red-400"
                        viewBox="0 0 20 20"
                        fill="currentColor"
                        aria-hidden="true"
                      >
                        <path
                          fill-rule="evenodd"
                          d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
                          clip-rule="evenodd"
                        />
                      </svg>
                    </div>
                    <div class="ml-3">
                      <h3 class="text-sm font-medium text-red-800">
                        <%= err %>
                      </h3>
                    </div>
                  </div>
                </div>
              <% end %>
            </div>
          <% end %>
        </ul>
      </div>

      <div class="flex flex-col flex-1 mt-10">
        <h2 class="text-base font-semibold leading-7 text-gray-900">Uploaded files to S3</h2>
        <p class="mt-1 text-sm leading-6 text-gray-600">
          Here is the list of uploaded files in S3. ๐Ÿชฃ
        </p>

        <p class={"
          #{if length(@uploaded_files_to_S3) == 0 do "block" else "hidden" end}
          text-xs leading-7 text-gray-400 text-center my-10"}>
          No files uploaded.
        </p>

        <ul role="list" class="divide-y divide-gray-100">
          <%= for file <- @uploaded_files_to_S3 do %>
            <!-- Entry information -->
            <li
              class="uploaded-s3-item relative flex justify-between gap-x-6 py-5"
              id={"uploaded-s3-#{file.uuid}"}
            >
              <div class="flex gap-x-4">
                <!--
                    Try to load the compressed image from S3. This is because the compression might take some time, so we retry until it's available
                    See https://stackoverflow.com/questions/19673254/js-jquery-retry-img-load-after-1-second.
                    -->
                <img
                  class="block max-w-12 max-h-12 w-auto h-auto flex-none bg-gray-50"
                  src={file.compressed_url}
                  onerror="imgError(this);"
                />
                <div class="min-w-0 flex-auto">
                  <p>
                    <span class="text-sm font-semibold leading-6 break-all text-gray-900">
                      Original URL:
                    </span>
                    <a
                      class="text-sm leading-6 break-all underline text-indigo-600"
                      href={file.url}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <%= file.url %>
                    </a>
                  </p>
                  <p>
                    <span class="text-sm font-semibold leading-6 break-all text-gray-900">
                      Compressed URL:
                    </span>
                    <a
                      class="text-sm leading-6 break-all underline text-indigo-600"
                      href={file.compressed_url}
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <%= file.compressed_url %>
                    </a>
                  </p>
                </div>
              </div>
            </li>
          <% end %>
        </ul>
      </div>
    </div>
  </div>
</div>

As you can see, the layout is fairly similar to the client version of the LiveView we've created earlier, albeit with a few differences.

Let's add a new route in the lib/app_web/controllers/router.ex file.

scope "/", AppWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/liveview", ImgupLive
    live "/liveview_clientless", ImgupNoClientLive # add this line
  end

Now, if we run mix phx.server and navigate to http://localhost:4000/liveview_clientless, you'll be prompted with the following screen.

Before being able to do anything, we have to make a small change. Go to config/dev.exs and change the live_reload parameter to this:

live_reload: [
  patterns: [
    ~r"priv/static/assets/.*(js|css|png|jpeg|jpg|gif|svg)$",
    ~r"priv/static/images/.*(js|css|png|jpeg|jpg|gif|svg)$",
    ~r"lib/app_web/(controllers|live|components)/.*(ex|heex)$"
  ]
]

When we run things locally, Phoenix uses a package called LiveReload. In this config we've just changed, LiveReload forces the app to refresh whenever there's a change detected in them. (check https://shankardevy.com/code/phoenix-live-reload/ for more information). Because we don't want our app to refresh every time a file is created locally, we've changed these paths accordingly.

And we're done! We have ourselves a fancy LiveView app that uploads files to S3 without any code on the client!

Awesome job! ๐ŸŽ‰

Please Star the repo! โญ๏ธ

If you find this package/repo useful, please star on GitHub, so that we know! โญ

Thank you! ๐Ÿ™

imgup's People

Contributors

cleop avatar dependabot[bot] avatar jackcarlisle avatar luchoturtle avatar nelsonic avatar nkamc avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

imgup's Issues

Feat: Filenames on `S3` should be the `cid` of the content ...

At present Unique File Names are using DateTime.utc_now():

image

This is does not take advantage of cid.
The idea of cid is that when a file is uploaded the name of the file is based on the actual content of the file ...
Therefore we need to read the binary of the file and feed that into cid/1 such that when the same file is uploaded again, it will have the same filename on S3.
The reason we want to do this is simple: we avoid duplicates.

Todo

  • update the function key variable to use a cid of the actual contents of the file.

What specs should we use for product images for a mobile centric e-commerce site?

I am working on a mobile-first project that is using image uploads for product images. In this instance the products are drinks (bottles). The client has asked what specs they should follow for providing the images for these products. We want to ensure images are of a good enough quality but that they also load quickly. So I want to know what specs I should request?

Chore: Compressed landscape (aspect) images add black border

At present the compressed images work fine until we have a wider image ...
https://jsfiddle.net/6t7p0eqc/7/

dwyl-imgup-compressed-images-add-black-border

Original

image

Compressed:

image

Todo

.resize({
width: 200,
height: 200,
fit: 'contain'
})

  • Should only constrain in one dimension e.g: only width or only height
    i.e. preserve the aspect ratio of images.

  • Redeploy the compressor app on AWS.

  • Confirm that compressed images are preserving aspect ratio (not adding black border)

[error] ** (UndefinedFunctionError) function :hackney.request/5 is undefined (module :hackney is not available)

The REST API PR #61 was merged. โœ…
But sadly when we attempt to upload an image via Hoppscotch we get the following error:
https://imgup.fly.dev/api/images

image

Logs: https://fly.io/apps/imgup/monitoring

imgup-hackney-request-fails
[error]  ** (UndefinedFunctionError) function :hackney.request/5 is undefined (module :hackney is not available)

[info] :hackney.request(:post, "https://s3.eu-west-3.amazonaws.com/imgup-original/zb2rhg1D3wGz3ps1wsUURdkSPLeFG5NTihkMtnpHHnXnFvSSo.png?uploads=1", 
[{"Authorization", "AWS4-HMAC-SHA256 
Credential=AKIAETC/20230614/eu-west-3/s3/aws4_request,SignedHeaders=content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date,Signature=65011ed0fc91075d3d429cda2ab70eda47ae4d3b214f187b34dbb0e8de8244a4"}, 
{"host", "s3.eu-west-3.amazonaws.com"}, {"x-amz-date", "20230614T102209Z"}, 
{"content-length", "0"}, {"x-amz-acl", "public-read"}, 
{"x-amz-content-sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}], 
"", [:with_body, {:recv_timeout, 30000}])

Very curious why the {"content-length", "0"} is it not reading the file? ๐Ÿ’ญ

@LuchoTurtle do you mind investigating this? ๐Ÿ™
Feel free to:
a) revert back to your version of the API from before I made changes.
b) deploy directly to Fly to figure out why the uploads don't work.

** (System.EnvError) could not fetch environment variable "AWS_ACCESS_KEY_ID" because it is not set

https://github.com/dwyl/imgup/actions/runs/5261384190/jobs/9509426771#step:4:26
image

/home/runner/work/_temp/.setup-beam/elixir/bin/mix local.rebar --force
  ** (System.EnvError) could not fetch environment variable "AWS_ACCESS_KEY_ID" because it is not set
      (elixir 1.14.2) lib/system.ex:706: System.fetch_env!/1
      /home/runner/work/imgup/imgup/config/config.exs:54: (file)
      (stdlib 4.1.1) erl_eval.erl:748: :erl_eval.do_apply/7
      (stdlib 4.1.1) erl_eval.erl:961: :erl_eval.expr_list/7
      (stdlib 4.1.1) erl_eval.erl:290: :erl_eval.expr/6
      (stdlib 4.1.1) erl_eval.erl:[28](https://github.com/dwyl/imgup/actions/runs/5261384190/jobs/9509426771#step:4:31)2: :erl_eval.expr/6
      (stdlib 4.1.1) erl_eval.erl:961: :erl_eval.expr_list/7
  Error: The process '/home/runner/work/_temp/.setup-beam/elixir/bin/mix' failed with exit code 1

Tried to add: https://github.com/dwyl/imgup/settings/secrets/actions but got the following error:
image

Failed to add secret, a secret with the same name (AWS_ACCESS_KEY_ID) already exists.

chore: Have bucket names imported env variables

As discussed in #70 (review), the buckets should be env variables because they are used across the application to upload files, retrieve the URLs of the latter, and show them to the person using the app/API.

Because the bucket names are implicitly used in these URLs and when creating the needed metadata to upload files from the LiveView, we ought to have them config'd and use them from there.

Edge cases in Upload

I've spotted some possible errors with the following code:

imgup/lib/app/upload.ex

Lines 23 to 28 in 81de078

def upload(image) do
# Create `CID` from file contents so filenames are unique
#
{:ok, file_binary} = File.read(image.path)
file_cid = Cid.cid(file_binary)
file_name = "#{file_cid}.#{Enum.at(MIME.extensions(image.content_type), 0)}"

Not an error in itself but I think we can make this a bit more readable:

file_extension = image.content_type
|> MIME.extensions()
|> List.first()

file_name = "#{file_cid}.#{file_extension}" 

Also MIME.extension can return an empty list, so in this case the file extension would be nil. Do you still want to upload the file if the extension is unknown? If yes maybe the file name should be just the file_cid
see https://hexdocs.pm/mime/MIME.html#extensions/1-examples
image

BUG: Couldn't upload files to S3. Open an issue on Github and contact the repo owner.

Getting the following error when attempting to upload on: https://imgup.fly.dev/liveview

Couldn't upload files to S3. Open an issue on Github and contact the repo owner.
image

@LuchoTurtle can you please investigate why if/when you have time? Thanks. ๐Ÿ™

Note: this is almost certainly related to the work done for the "Edge Cases" PR: #86 / #69
The tests all pass on that branch so either the LiveView upload is insufficiently tested or the tests are a false positive. ๐Ÿ’ญ

Rotate images after they have been uploaded

When uploading an image from a mobile device, the image is often shot in landscape or "upside-down" mode (e.g. selfies). Users are begging us for a way of rotating images so they appear the right way up.
Example UI

`Who` can/should use our image uploading + resizing service?

The "demo" version of the imgup App will be open to allow people to test it.
We will not DELETE the files unless the AWS S3 bill gets out of hand e.g: bots start uploading.
I don't expect nefarious people to discover/use the imgup App ...
but if they do we will rapidly respond and put all uploading behind Auth.
The REST API will have Auth ASAP because that is where abuse is most likely to originate.
See:

But we need to enable logging: dwyl/logs#1
so that we can:
A. See who is trying the demo and from where.

Bug: Cannot include `S3` image in a `GitHub` issue comment

We are able to upload images to S3 via REST API ๐ŸŽ‰
URL: https://imgup.fly.dev/api/images
Docs: https://github.com/dwyl/imgup/blob/main/api.md#4-testing-the-api-from-hoppscotch

image
{
  "compressed_url": "https://s3.eu-west-3.amazonaws.com/imgup-compressed/zb2rhoFgi1PXSfoeFi9PGT3R33fpq4iAsQJRuU2Yoy7yMzhj3.jpg",
  "url": "https://s3.eu-west-3.amazonaws.com/imgup-original/zb2rhoFgi1PXSfoeFi9PGT3R33fpq4iAsQJRuU2Yoy7yMzhj3.jpg"
}

The problem is when we attempt to view one of these images directly in the browser e.g. by copy-pasting the URL into a new browser tab: https://s3.eu-west-3.amazonaws.com/imgup-original/zb2rhoFgi1PXSfoeFi9PGT3R33fpq4iAsQJRuU2Yoy7yMzhj3.jpg

It automatically downloads the file instead of displaying it in the browser.
I suspect this is because the MIME Type is not correctly set on S3 ...

This is why it does not display the image in a GitHub issue comment or .md file ...

![img](https://s3.eu-west-3.amazonaws.com/imgup-original/zb2rhoFgi1PXSfoeFi9PGT3R33fpq4iAsQJRuU2Yoy7yMzhj3.jpg)

img

... ๐Ÿคทโ€โ™‚๏ธ

@LuchoTurtle do you have time to investigate this?
I've tried reading the docs: https://hexdocs.pm/ex_aws_s3/ExAws.S3.html#t:initiate_multipart_upload_opt/0
but can't seem to find where to put the Content-Type for the uploaded file ... ๐Ÿคทโ€โ™‚๏ธ

Updating the SDK example: how much public access should be granted?

I am getting this warning whilst following the SDK example:

image

I chose the following access permissions:
image

Based on the guidance from this step of the existing tutorial:

image

I wanted to get a second opinion on whether I had interpreted the updated version of the steps correctly and therefore whether I should react to this warning message?

Server side image uploads

We are trying to implement an image upload from our server.
We need to replicate the form submit from the direct-upload example on the back end but we're having trouble structuring our http POST request.
The form data that we want to send are the params returned from our getS3Credentials function.
The url of the form is the endpoint url from the same function.

Our form looks like this:

<form>
  <input id="fileInput" type="file" name="file" onchange="uploadDemo.saveFile(this.files)"/>
</form>
<button id="submit" onclick="uploadDemo.submitFile()">Submit</button>

And this is what we're dynamically adding before we submit the form:

function buildAndSubmitForm (s3Data) {
    var form = document.querySelector('form')
    var keyInput = document.createElement('input')
    keyInput.setAttribute('type', 'hidden')
    keyInput.setAttribute('name', 'key')
    keyInput.setAttribute('value', s3Data.filename)
    form.setAttribute('method', 'post')
    form.setAttribute('action', s3Data.endpoint_url)
    form.setAttribute('enctype', 'multipart/form-data')
    form.insertBefore(keyInput, form.firstChild)
    form.url = s3Data.endpoint_url
    form.formData = s3Data.params
    form.submit()
  }

Does anyone know how our POST request should look in order for our form data to be sent in the same fashion as our front end example?

New Version!

The time has come to update (revive) this project and bring it into 2016!

ย Steps:

  • git mv README.md REF.md (temporarily keep a reference to the old README.md so we can pluck sections from it e.g. Research on Orientation and other uploaders etc.)
  • Create a brand new readme with a new problem statement (this can be inspired by the old one but needs to be fresh)

Next we need to Remove all references to Meteor (people writing meteor apps will still be able to use this project, but we need to break the tight-coupling)

  • git rm -r .meteor
  • git rm -r client
  • git rm -r packages
  • git rm -r private
  • git rm -r public
  • git mv server lib
  • git mv lib/server_save_file.js lib/index.js (we will remove most of the code in this file shortly but it gives us some history #nostalgia ...)
  • git rm npm-debug.log
  • git mv packages.json package.json (convert from Meteor project to standard node.js project)
  • git rm npm-debug.log
  • Update the .gitignore with the relevant definitions from https://github.com/github/gitignore/blob/master/Node.gitignore (_we only need to ignore node_modules, npm-debug.log* and coverage for now...
  • git rm smart.json
  • git rm smart.lock
  • git rm style
  • git rm style.css

Hi @jackcarlisle please create a pull request with the above changes and call it "reset" and assign to me. Then we can start working on the actual features... thanks! ๐Ÿ‘

Task List

  • Current example uses blueimp and jquery to perform the request to S3 - re-write in vanilla XHR
  • Convert the server from express to hapi
  • Add tests for server
  • Return bucket name as an environment variable from the server
  • Add end-to-end tests (nightwatch) #15

Example/Showcase of Image Upload UX/UI?

Image uploading is a common feature in many web/mobile applications.

We are going to be using the Image Upload feature extensively in Time
to allow people to upload a photo showing evidence of their completed activity.
(if you cannot visualise the use-case of this, please comment in: dwyl/app#215)

Additionally there are several classes of applications where image uploading is relevant:

  • Dedicated Photo Sharing App (the obvious one)
  • Messaging/Chat (where "a picture is worth a thousand words") Look no further than SnapChat!
  • Personal/Company Finance & Accounting (Uploading Receipts)

By building a clearly-explained Image Upload example/tutorial with re-useable code,
we can help others get creative and spawn new Apps/Projects in the community.

Todo

  • Create an start-to-finish Image Upload Example
  • Deploy it to Heroku
  • Make the code generic and modular so that anyone can use it in their project.

Questions we need to ask/answer:

  • How do we secure our /upload endpoint to avoid people using it maliciously to upload undesirable images.
    • Can we restrict the Origin of the upload?
      • Can the Origin be spoofed by a command-line script?

I'm not saying that we are going to create an "image upload" product,
But the ability to upload files/images is a "standalone" feature that people pay for!
e.g: https://apps.shopify.com/uploadery
This is a feature that has real value to end-users so if we build something great
not only will the people using the @dwyl app find it useful,
the wider community will use it and help us to improve it!

S3 bucket access denied

I'm having issues with the config.json file I think (credentials or permissions).

This is the error I'm getting on the frontend side:

image

This one on Meteor console:

image

If I try loading the link that gave an error on the frontend I get this:

Imรกgenes integradas 3
image

S3 account:

image

config.json file on the App:

image

I gave a bucket policy for testingboris since I was getting denied error but didn't do the trick:

image

image

It seems to be a permission issue with the bucket.

Another thing I had to do with the project was to change require() to Meteor.require if not I got an error on console. (require is not defined).

Really Appreciate some help on this!

What is the best way to allow for multiple image uploads?

What is the best way to allow for multiple image uploads?

For an e-commerce site where a product can has one primary photo plus multiple secondary photos, what is the best way to enable uploading multiple images?

  1. If you upload multiple images one at a time just like a single upload app, what naming conventions should you follow in order to store the images linked to a particular product? E.g. product a1b has images a1b-1, a1b-2 etc. Is using an end number useful? Could this number go into double or triple digits if necessary?
  2. How can you enable users to upload multiple images in one go? E.g.
    image
  3. How can you differentiate between the primary and secondary images? How do you update the primary image?

Question 1 is of the highest priority for me right now but I will also want to consider the other questions in due course.

API Upload fails on Fly.dev "Required key: :secret_access_key is nil in config!"

The app is deployed to Fly: https://imgup.fly.dev/
image

But when we attempt to upload via the REST API in Hoppscotch we see the following error:
image

{
  "errors": {
    "detail": "Error uploading file #26"
  }
}

Viewing the monitoring: https://fly.io/apps/imgup/monitoring we see the following error in the logs:
image

** (MatchError) no match of right hand side value: {:error, "Required key: :secret_access_key is nil in config!"}

Just updated the Secrets: https://fly.io/apps/imgup/secrets

image

Still getting the same issue. Works on localhost ... ๐Ÿคทโ€โ™‚๏ธ

Going to investigate this now as the code is still fresh in my head. ๐Ÿ”

How to use this repo without a node backend?

I want to use this repo for an elixir project and saw the section about setting up a node server and using node packages crypto and path. I don't think it's good practice to have the two running simultaneously.

We don't have the time on this occasion to rewrite this repo entirely in elixir and so I wanted to know if there is another alternative?

Feat: Image Upload API

Once we have the basic Web-based image uploading working there are many enhancements we can make. โœจ
Hopefully by making everything Open Source - as always - we invite contributions from the community. ๐Ÿคž
However from our perspective @dwyl what we want is the ability to upload from our Flutter (Native Mobile) App.

Todo

  • Create a Secure REST API endpoint that allows a client e.g. JS or Flutter to upload an image
  • Ideally should be streaming to provide visual feedback of the upload progress
  • Should return the URL of the image once uploaded

Note: this issue is not complete. It's a place-holder for the discussion around features/requirements.
Once we have #51 working in the Web interface, this is the next logical step.

Feat: Logging + Metrics

This is "on hold" ... pending dwyl/logs#1

At present the imgup app has no DB ๐Ÿ’ญ
So we have zero insight into what images are being uploaded. ๐Ÿ™ˆ ๐Ÿคทโ€โ™‚๏ธ
This is "OK" for a basic demo... :shipit:
But it's not great for a "production" app where we want to track metrics. ๐Ÿ“ˆ
We need to "productionize"! ๐Ÿš€

Todo

  • Define the DB schema for storing anon logging data.
    We can borrow (or draw inspiration) from: dwyl/auth/lib/auth/log.ex
    • user_agent table should mimmic the one in auth: lib/auth/user_agent.ex so that we store the relevant detail. The other field we could add is
  • Create the necessary migrations for the schema we define.
  • Create a simple interface that logs required events ...

Testing using the sdk-upload using Hapi's server.inject

We are not using nightwatch in our project at the moment, but the tests in the sdk-upload example are only done using nightwatch. It would be really helpful to see another way to test this upload method.

We are using Hapi's server.inject method and after a lot of playing around, eventually managed to upload a file; we only managed this with a .txt file. There are a couple of working examples linked at the bottom which may hold the solution.

Here is a link to the PR on my project that contains the image upload with a test that successfully uploads a file to our S3 bucket using server.inject.

I thought it would be useful to copy our method into an issue here, and we can develop it as and when we work out a better/more comprehensive way of testing

// our form has the inputs: name, file_name, mission_statement, logo
// logo represents the file, and file_name is the name of the file being uploaded
// name and mission statement are additional fields that our form needs to track
// at the moment, we are struggling to upload an image, so the logo is actually a .txt file
var payloadString = 
  '--AaB03x\r\n' +
  'content-disposition: form-data; name="name"\r\n' + // input on our form called `name`
  '\r\n' +
  'Apple\r\n' + // The value of the input `name` is "Apple"
  '--AaB03x\r\n' +
  'content-disposition: form-data; name="file_name"\r\n' + // input on our form called `file_name`
  '\r\n' +
  'foxy.txt\r\n' +  // The value of the input `file_name` is "foxy.txt"
  '--AaB03x\r\n' +
  'content-disposition: form-data; name="mission_statement"\r\n' + // input on our form called `mission_statement`
  '\r\n' +
  'Change the economy!\r\n' + // The value of the input `mission_statement` is "Change the economy!"
  '--AaB03x\r\n' +
  'content-disposition: form-data; name="logo"; filename="foxy.txt"\r\n' + // input on our form called `logo`
  'Content-Type: text/plain\r\n' + // content type of the file being sent in the form
  '\r\n' +
  'foxxxxyy\r\r\n' +  // contents of the file being sent in the form
  '--AaB03x--\r\n'


var addLogoOptions = {
    method: 'POST',
    url: '/add-image',
    headers: {
      'Content-Type': 'multipart/form-data; boundary=AaB03x' // notice this boundary repeated before/after each input in the payload string
    },
    payload: payloadString
  };
}


// the tape test
tape('/add-image route successfully adds an image to S3 --> ' + __filename, function (t) {
  server.inject(addLogoOptions, function (res) {
    t.equal(res.statusCode, 200, 'status code is 200 we can upload a file to S3');
    t.end();
  });
});

This test passes, the payload string was taken and adapted from this issue on the hapi repo

It looks like there might be a slightly simpler way of testing the file upload in this issue

Finally this is a working example of the potentially more simple way of solving this problem

attempting install

W20150221-18:55:05.788(-8)? (STDERR) ReferenceError: require is not defined
W20150221-18:55:05.788(-8)? (STDERR) at app/node_modules/imagemagick/imagemagick.js:1:52
W20150221-18:55:05.789(-8)? (STDERR) at app/node_modules/imagemagick/imagemagick.js:417:3

How to test S3 image uploading on your project?

How should we test the image uploading functionality on a project?

I can see this nightwatch example on this repo:
https://github.com/dwyl/image-uploads/blob/master/examples/sdk-upload/test/e2e/sdk-upload.test.js

However my concern is that every time you perform this test you are uploading a new file to S3. Unless someone in a team remembers to delete the test files on a regular basis you could end up with a lot of files. This comes as a cost on S3 if it was a large amount of data being stored.

Would it be possible/better to delete the files once they're uploaded? Or can we mock the upload in some way but still test it?

Epic: Upload images to `AWS S3` via `Phoenix LiveView`

At present this repo is just a loose collection of notes and some sample code.
What it should be instead is an end-to-end working example of uploading, compressing, resizing, rotating and cropping images.

Goal

Our goal with image uploads is to be able to seamlessly upload any type of image
have it saved in a reliable place e.g. S3 (though we can research cheaper comparable options later...)
and display that image in our App and other places.

We want to be able to upload in a Web interface and in a Flutter App.

Todo

  • Move all existing code to an _archive directory in case we need to refer to any of it.
    • Move the README.md to _archive too so you have a fresh start.

    Note: it will all be DELETED once we have fully working version.

  • Watch Chris McCord's LiveView Uploads Demo:

Phoenix LiveView Uploads Deep Dive: https://youtu.be/PffpT2eslH8
image

If we can get to the point where we are uploading images in a Phoenix LiveView App we will already be showing great progress.
Don't worry too much about the UI/UX at this stage. Use TailwindUI:
https://tailwindui.com/components/application-ui/forms/form-layouts
image

@LuchoTurtle as discussed on standup this morning, this should be the next "big chunk" of work you should attempt to tackle.
If you have questions, please comment. ๐Ÿ™

fontawesome

looks like old package is no longer available, thus project does not work after cloning git repo

Should we Upload images to Google Drive or Google Photos?

With most cloud storage services there is a cost associated with storing photos.
Granted, the cost is low e.g $0.03 per GB (per month) on AWS S3 ... but it can add up if you have many photos e.g. 100GB is $36 per year and that would only increase over time...

So, if instead of using AWS S3 we use Google Drive or Google Photos we get 15 GB "Free":
drive-storage

or "Unlimited Free" (Google Photos):
google-photos-unlimited

See:

@iteles thoughts...?

Chore: Remove Image Expiry (Don't delete images from S3)

At present there is a policy to automatically DELETE images after a period of time.
While this is useful for some projects, it's not a requirement for us.

Todo

  • Please remove this auto-delete policy so that images are kept.

Note: we can easily manually delete images if needed.

`imgup` Fly.io App deployment overwriting AWS env vars leading to failure

At present the automatic continuous deployment (CD) from GitHub actions to Fly.io for the imgup app works partially.

https://imgup.fly.dev/liveview

imgup-liveview-upload-working.mov

However the REST API fails when we attempt to upload in Hoppscotch:
image

The app was recently deployed: https://fly.io/apps/imgup
image

In the logs following the failed API request we see the following error:
https://fly.io/apps/imgup/monitoring
image

[error] ** (MatchError) no match of right hand side value: {:error, "Required key: :secret_access_key is nil in config!"}

Which relates to the following line of code:

Logger.error(Exception.format(:error, e, __STACKTRACE__))

Not super informative, but we know the upload is failing due to unavailability of the AWS :secret_access_key ... ๐Ÿ™ƒ

So we know what needs to be fixed ... ๐Ÿคž

Todo

  • Remove the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from ci.yml:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  • Update the FLY_API_TOKEN to the dwyl account.
  • Create PR updating.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.