-
-
Save chrismccord/37862f1f8b1f5148644b75d20d1cb073 to your computer and use it in GitHub Desktop.
| 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 |
Small gotcha that might be worth knowing about if someone else runs into the same problem.
Setting
{"acl": "public-read"},and"acl" => "public-read",will cause an error when submitting the upload if your S3 bucket has "Block public access (BlockPublicAcls)" enabled. This is a common thing to have if the bucket is behind a CloudFront distribution and not intended to be accessed directly.If this applies to you, simply remove the two lines of code mentioned above and things should work fine.
This is a life saver! lost 4 hours figuring out the error
For future reference if you're running this code in an environment where you authenticate with short-lived credentials, such as ECS + authenticating with instance roles with an iam policy for s3 access, you may need to add additional configuration for the short-lived token. Your ExAws(or comparable) module should expose that short lived token.
- Add the token in the
encoded_policy:
+ {\"x-amz-security-token\": \"#{security_token}\"},- Add the token in the
fields:
+ Map.put(fields, "x-amz-security-token", security_token),
Hey.
I decided to use MinIO and ExAWS.
No errors encountered.
Thanks for your response @eriknaslund