Skip to content

Instantly share code, notes, and snippets.

@plicjo
Last active March 23, 2024 12:19
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save plicjo/5e5ced381f2b71d69d98b3e48885aacf to your computer and use it in GitHub Desktop.
Save plicjo/5e5ced381f2b71d69d98b3e48885aacf to your computer and use it in GitHub Desktop.
LiveView Uploads to S3
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
{:ok, fields} =
SimpleS3Upload.sign_form_upload(
key: "public/my-file-name",
content_type: "image/png",
max_file_size: 10_000,
expires_in: :timer.hours(1)
)
"""
def sign_form_upload(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 config do
%{
region: region(),
access_key_id: Application.fetch_env!(:liveview_mastery, :access_key_id),
secret_access_key: Application.fetch_env!(:liveview_mastery, :secret_access_key)
}
end
def meta(entry, uploads) do
s3_filepath = s3_filepath(entry)
{:ok, fields} =
sign_form_upload(
key: s3_filepath,
content_type: entry.client_type,
max_file_size: uploads.photo.max_file_size,
expires_in: :timer.hours(1)
)
%{
uploader: "S3",
key: s3_filepath,
url: "https://#{bucket()}.s3.#{region()}.amazonaws.com",
fields: fields
}
end
def bucket do
Application.fetch_env!(:liveview_mastery, :bucket)
end
def region do
Application.fetch_env!(:liveview_mastery, :region)
end
def s3_filepath(entry) do
"#{entry.uuid}.#{ext(entry)}"
end
def entry_url(entry) do
"http://#{bucket()}.s3.#{region()}.amazonaws.com/#{entry.uuid}.#{ext(entry)}"
end
def presign_entry(entry, socket) do
uploads = socket.assigns.uploads
s3_filepath = s3_filepath(entry)
{:ok, fields} = sign_form_upload(
key: s3_filepath,
content_type: entry.client_type,
max_file_size: uploads.photo.max_file_size,
expires_in: :timer.hours(1)
)
meta = %{uploader: "S3", key: s3_filepath, url: "https://#{bucket()}.s3.amazonaws.com", fields: fields}
{:ok, meta, socket}
end
def ext(entry) do
[ext | _] = MIME.extensions(entry.client_type)
ext
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
defmodule LiveviewMasteryWeb.UploadComponent do
use LiveviewMasteryWeb, :live_component
def render(assigns) do
~H"""
<div>
<%= hidden_input @form, :photo_url %>
<%= error_tag @form, :photo_url %>
<div class="col-span-4 sm:col-span-2" phx-drop-target={@uploads.photo.ref}>
<div class="mt-2 border-2 border-gray-300 border-dashed rounded-md px-6 pt-5 pb-6 flex justify-center">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<div class="flex text-sm text-gray-600 ml-20">
<.live_file_input upload={@uploads.photo} />
</div>
<p class="text-xs text-gray-500">
PNG or JPG
</p>
</div>
</div>
</div>
<div class="col-span-4 sm:col-span-2">
<%= for {_ref, msg} <- @uploads.photo.errors do %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: x-circle -->
<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" 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.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= Phoenix.Naming.humanize(msg) %>
</h3>
</div>
</div>
</div>
<% end %>
<%= for entry <- @uploads.photo.entries do %>
<.live_img_preview entry={entry} width="75" />
<div class="py-5">
<div class="flex items-center">
<div class="w-0 flex-1">
<dd class="flex items-baseline">
<div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<svg class="self-center flex-shrink-0 h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<%= entry.progress %>%
</div>
</dd>
</div>
</div>
</div>
<% end %>
</div>
</div>
"""
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment