Skip to content

Instantly share code, notes, and snippets.

@baldwindavid
Last active October 1, 2019 05:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save baldwindavid/0516131d9a768d57c96af0fd98206e7d to your computer and use it in GitHub Desktop.
Save baldwindavid/0516131d9a768d57c96af0fd98206e7d to your computer and use it in GitHub Desktop.
Some methods to generate fields to be used for direct s3 upload
defmodule S3.DirectUpload do
import ExAws.Auth.Utils, only: [amz_date: 1]
import ExAws.S3.Utils
@default_max_size_kilobytes 5_242_880
@default_expiry_seconds 3600
@type canned_acl ::
:private
| :public_read
| :public_read_write
| :authenticated_read
| :bucket_owner_read
| :bucket_owner_full_control
@type presigned_fields_opts :: [
{:expires_in, integer}
| {:acl, canned_acl()}
| {:policy_conditions_func, function()}
| {:added_fields, map()}
| {:max_size, integer}
]
@doc """
Generate the endpoint url for Browser-Based Uploads.
"""
@spec browser_upload_endpoint(config :: map(), bucket :: binary()) :: binary()
def browser_upload_endpoint(config, bucket) do
config.scheme <> bucket <> "." <> config.host
end
@doc """
Generate a map containing pre-signed values for S3 Uploads directly from the browser.
These fields are based on the [AWS Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-authentication-HTTPPOST.html)
Optional paramenters:
:expires_in - Defines the expiration time of the signature (default is 3600)
:acl - Accessibility of the object (default is :private)
:policy_conditions_func - A function that takes the fields (sans policy and
x-amz-signature since those are calculated based upon said policy)
and generates a policy to ensure the fields do not get
tampered with. By default, this function is set call `default_policy_conditions`.
That default method requires that the bucket matches exactly as specified.
It also requires that all but the last segment of the key path matches
what is specified. This means that someone can't change the form field to
upload to an unexpected path. It also uses the max_size option to limit file
sizes. All other fields require an exact match.
A custom function can be passed for special requirements. This function will
receive the bucket name, fields, and max_size as arguments. Options and matchers can
be referenced at https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
:added_fields - A number of fields will always be set automatically by this
method (key, acl, x-amz-algorithm, x-amz-credential, and x-amz-date). AWS
supports multiple additional fields such as success_action_status, Content-Type,
etc. These are not consistently named so need to be passed
here as a map with string keys. They should exactly match the key names listed
at https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTForms.html
The defaults for key, acl, etc. should be fine for most cases, but if they
are not, this option allows overriding those options.
Example
config = ExAws.Config.new(:s3)
bucket = "my-bucket"
fields =
Utilities.S3Direct.presigned_fields(
config,
bucket,
"some/path/to/${filename}",
added_fields: %{"success_action_status" => "201"},
max_size: 10_000_000
)
"""
@spec presigned_fields(
config :: map(),
bucket :: binary,
key :: binary,
opts :: presigned_fields_opts
) :: map()
def presigned_fields(config, bucket, key, opts \\ []) do
expires_in = Keyword.get(opts, :expires_in, @default_expiry_seconds)
acl = Keyword.get(opts, :acl, :private)
added_fields = Keyword.get(opts, :added_fields, %{})
max_size = Keyword.get(opts, :max_size, @default_max_size_kilobytes)
policy_conditions_func =
Keyword.get(opts, :policy_conditions_func, &default_policy_conditions/3)
timestamp = :calendar.universal_time()
fields =
%{
"key" => key,
"acl" => normalize_param(acl),
"x-amz-algorithm" => "AWS4-HMAC-SHA256",
"x-amz-credential" =>
ExAws.Auth.Credentials.generate_credential_v4("s3", config, timestamp),
"x-amz-date" => amz_date(timestamp)
}
|> Map.merge(added_fields)
policy =
gen_policy(config, policy_conditions_func, fields, timestamp, expires_in, bucket, max_size)
signature = ExAws.Auth.Signatures.generate_signature_v4("s3", config, timestamp, policy)
fields
|> Map.put("policy", policy)
|> Map.put("x-amz-signature", signature)
end
def gen_policy(config, policy_conditions_func, fields, timestamp, expires_in, bucket, max_size) do
policy_conditions = policy_conditions_func.(fields, bucket, max_size)
policy_expiration =
timestamp_plus(timestamp, expires_in)
|> NaiveDateTime.from_erl!()
|> NaiveDateTime.to_iso8601()
policy = %{
expiration: Enum.join([policy_expiration, "Z"]),
conditions: policy_conditions
}
policy
|> config.json_codec.encode!()
|> Base.encode64()
end
defp default_policy_conditions(fields, bucket, max_size) do
fields
|> Enum.reject(fn {k, v} -> k == "key" end)
|> Enum.map(fn {k, v} -> %{k => v} end)
|> Kernel.++([
%{"bucket" => bucket},
["starts-with", "$key", key_path_for(fields["key"])],
["content-length-range", 1, max_size]
])
end
defp key_path_for(key) do
key
|> String.split("/")
|> Enum.reverse()
|> tl()
|> Enum.reverse()
|> Enum.join("/")
|> (&(&1 <> "/")).()
end
def timestamp_plus(timestamp, additional) do
timestamp
|> :calendar.datetime_to_gregorian_seconds()
|> Kernel.+(additional)
|> :calendar.gregorian_seconds_to_datetime()
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment