Skip to content

Commit

Permalink
following simple s3 upload guide
Browse files Browse the repository at this point in the history
  • Loading branch information
zackcreach committed Dec 23, 2024
1 parent 923b153 commit 188b433
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 1 deletion.
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'phoenix_html'
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import Uploaders from './uploaders'
import topbar from '../vendor/topbar'

const csrfToken = document
Expand All @@ -29,6 +30,7 @@ const csrfToken = document
const liveSocket = new LiveSocket('/live', Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
uploaders: Uploaders,
})

// Show progress bar on live navigation and form submits
Expand Down
28 changes: 28 additions & 0 deletions assets/js/uploaders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const Uploaders = {}

Uploaders.S3 = function (entries, onViewError) {
entries.forEach((entry) => {
let formData = new FormData()
let { url, fields } = entry.meta
Object.entries(fields).forEach(([key, val]) => formData.append(key, val))
formData.append('file', entry.file)
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)
})
}

export default Uploaders
25 changes: 24 additions & 1 deletion lib/gifmaster_web/live/home_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ defmodule GifmasterWeb.HomeLive do
gifs: AsyncResult.loading()
)
|> assign_async(:gifs, fn -> {:ok, %{gifs: Catalog.get_gifs()}} end)
|> allow_upload(:gif, accept: [".gif"], max_entries: 1)
|> allow_upload(:gif, accept: [".gif"], max_entries: 1, external: &presign_upload/2)
|> ok()
end

Expand Down Expand Up @@ -49,6 +49,29 @@ defmodule GifmasterWeb.HomeLive do
"""
end

defp presign_upload(entry, socket) do
uploads = socket.assigns.uploads
bucket = "gems.gifmaster5000.com"
key = entry.client_name

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, 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: "http://#{bucket}.s3-#{config.region}.amazonaws.com", fields: fields}
{:ok, meta, socket}
end

def handle_event("search", %{"search" => search}, socket) do
socket
|> assign(gifs: AsyncResult.loading())
Expand Down
127 changes: 127 additions & 0 deletions lib/gifmaster_web/simple_s3_upload.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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.hmac(:sha256, secret, msg)
end

0 comments on commit 188b433

Please sign in to comment.