image uploads
- Why? π€·
- What? π
- Who? π€
- How? π»
- Build It! π©βπ»
- 0. Creating a fresh
Phoenix
project - 1. Adding
LiveView
capabilities to our project - 2. Local file upload and preview
- 3. File validation
- 4. Uploading image to
AWS S3
bucket - 5. Feedback on progress of upload
- 6. Unique file names
- 7. Resizing/compressing files
- 8. A note when deploying online
- 9. Uploading files without
Javascript
- 0. Creating a fresh
- Please Star the repo! βοΈ
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.
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.
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
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
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:
Clone the latest code:
git clone [email protected]:dwyl/imgup.git && cd imgup
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 calledex_aws
.
Run the commands:
mix setup && mix s
Then open your web browser to: localhost:4000 and start uploading!
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!
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.
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/>
forLiveView
file upload. We've wrapped this component with an element that is annotated with thephx-drop-target
attribute pointing to the DOMid
of the file input. This allows people to click on theUpload
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 usinglive_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 aremove-selected
event, which passes the entry reference to the event handler. The latter makes use of thecancel_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.
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.
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.
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.
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.
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 withS3
buckets. Find more information aboutAWS
in https://github.com/dwyl/learn-amazon-web-services and aboutS3
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
.
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!
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.
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! π
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 themount/3
function in our LiveViewlib/a--Web/live/imgup_live.ex
file. - removed the "Cancel" button.
- added a
<progress>
HTML element that uses theentry.progress
value. This value is updated in real-time because of the uploader hook we've implemented inassets/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!
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!
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.
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!
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 ourAWS 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
.
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 ourS3
buckets when deploying ourSAM
template. - the
Resources
block has all the resources needed. In our case, we have theUncompressedBucket
andCompressedBucket
, which are both self-explanatory. Both buckets then have their respective bucket names set from the parameters we previously defined. TheImageCompressorLambda
is the Lambda Function, which uses theNode.js
runtime and points tosrc/index.handler
location. Under thePolicies
section, we give the Lambda function the appropriate permissions to read data from theUncompressedBucket
and write toCompressedBucket
. And lastly, we configure the event trigger for the Lambda function. The event is fired any time an object is created in theUncompressedBucket
.
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 intemplate.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 to200x200
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 theCompressedBucket
with the same key as the original.
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!
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!
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.
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>
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! π
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.
Note:
This section assumes you've implemented the
API
, as described inapi.md
. We are going to be using anupload/1
function to directly upload a given file to anS3
bucket in ourLiveView
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 theUpload
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!
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 usedallow_upload/3
with theauto_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 usingpresign_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 theS3
bucket. -
handle_progress/3
is automatically invoked after a file is selected by the person - this is becauseauto_upload
is set totrue
. Weconsume_uploaded_entry
to get the file locally and soLiveView
knows it's been uploaded. Inside the callback of this function, we create the file locally and create the object to be added to theuploaded_files_locally
array in the socket assigns. Each object follows the structure%{image_url: url, url_path: path, errors: []}
. The files are being saved insidepriv/static/image_uploads
. -
handle_event("upload_to_s3, params, socket)
will be invoked when the person clicks on theUpload
button to upload a given locally uploaded file. It will call theApp.Upload.upload/1
function implemented inapi.md
. If the file is correctly uploaded, it is added to theuploaded_files_to_s3
socket assigns. If not, an error is added to the file object inside theuploaded_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!
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! π
If you find this package/repo useful, please star on GitHub, so that we know! β
Thank you! π