├── test ├── test_helper.exs └── s3_direct_upload_test.exs ├── config ├── test.exs └── config.exs ├── mix.lock ├── lib ├── s3_direct_upload │ ├── date_util.ex │ └── static_date_util.ex └── s3_direct_upload.ex ├── .gitignore ├── LICENSE.md ├── README.md └── mix.exs /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Test configuration 4 | config :s3_direct_upload, 5 | aws_access_key: "123abc", 6 | aws_secret_key: "abc123", 7 | aws_s3_bucket: "s3-bucket", 8 | date_util: S3DirectUpload.StaticDateUtil 9 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.1.1", "433136b7f2e99cde88b745b3a0cfc3fbc81fe58b918a09b40fce7f00db4d8187", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}, 3 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}} 4 | -------------------------------------------------------------------------------- /lib/s3_direct_upload/date_util.ex: -------------------------------------------------------------------------------- 1 | defmodule S3DirectUpload.DateUtil do 2 | 3 | @moduledoc false 4 | 5 | def today_datetime do 6 | %{DateTime.utc_now | hour: 0, minute: 0, second: 0, microsecond: {0,0}} 7 | |> DateTime.to_iso8601(:basic) 8 | end 9 | 10 | def today_date do 11 | Date.utc_today 12 | |> Date.to_iso8601(:basic) 13 | end 14 | 15 | def expiration_datetime do 16 | DateTime.utc_now() 17 | |> DateTime.to_unix() 18 | |> Kernel.+(60 * 60) 19 | |> DateTime.from_unix!() 20 | |> DateTime.to_iso8601() 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017 Andrew Kappen 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /lib/s3_direct_upload/static_date_util.ex: -------------------------------------------------------------------------------- 1 | defmodule S3DirectUpload.StaticDateUtil do 2 | 3 | @moduledoc false 4 | 5 | def today_datetime do 6 | static_datetime() 7 | |> DateTime.to_iso8601(:basic) 8 | end 9 | 10 | def today_date do 11 | static_date() 12 | |> Date.to_iso8601(:basic) 13 | end 14 | 15 | def expiration_datetime do 16 | static_datetime() 17 | |> DateTime.to_unix() 18 | |> Kernel.+(60 * 60) 19 | |> DateTime.from_unix!() 20 | |> DateTime.to_iso8601() 21 | end 22 | 23 | defp static_datetime do 24 | ~N[2017-01-01 00:00:00] 25 | |> DateTime.from_naive!("Etc/UTC") 26 | end 27 | 28 | defp static_date do 29 | ~D[2017-01-01] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # Application configuration 12 | config :s3_direct_upload, 13 | aws_access_key: System.get_env("AWS_ACCESS_KEY_ID"), 14 | aws_secret_key: System.get_env("AWS_SECRET_ACCESS_KEY"), 15 | aws_s3_bucket: System.get_env("AWS_S3_BUCKET") 16 | 17 | 18 | # Static test configuration 19 | if Mix.env == :test do 20 | import_config "#{Mix.env}.exs" 21 | end 22 | -------------------------------------------------------------------------------- /test/s3_direct_upload_test.exs: -------------------------------------------------------------------------------- 1 | defmodule S3DirectUploadTest do 2 | use ExUnit.Case 3 | doctest S3DirectUpload 4 | 5 | import Map, only: [get: 2] 6 | 7 | test "presigned_json" do 8 | upload = %S3DirectUpload{ 9 | file_name: "file.jpg", 10 | mimetype: "image/jpeg", 11 | path: "path/in/bucket" 12 | } 13 | result = S3DirectUpload.presigned_json(upload) |> Poison.decode! 14 | assert result |> get("url") == "https://s3-bucket.s3.amazonaws.com" 15 | credentials = result |> get("credentials") 16 | assert credentials |> get("acl") == "public-read" 17 | assert credentials |> get("key") == "path/in/bucket/file.jpg" 18 | assert credentials |> get("policy") |> String.slice(0..9) == "eyJleHBpcm" 19 | assert credentials |> get("x-amz-algorithm") == "AWS4-HMAC-SHA256" 20 | assert credentials |> get("x-amz-credential") == "123abc/20170101/us-east-1/s3/aws4_request" 21 | assert credentials |> get("x-amz-date") == "20170101T000000Z" 22 | assert credentials |> get("x-amz-signature") == "1c1210287ea2cb1c915ee11b9515b2d811f4b21a90e78a45f12465974ebb95f1" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S3DirectUpload 2 | 3 | Pre-signed S3 upload helper for client-side multipart POSTs in Elixir. 4 | 5 | See: 6 | 7 | [Browser-Based Upload using HTTP POST (Using AWS Signature Version 4)](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html) 8 | 9 | [Task 3: Calculate the Signature for AWS Signature Version 4](http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html) 10 | 11 | ## Installation 12 | 13 | S3DirectUpload can be installed by adding `s3_direct_upload` to your 14 | list of dependencies in `mix.exs` and then running `mix deps.get`: 15 | 16 | ```elixir 17 | def deps do 18 | [{:s3_direct_upload, "~> 0.1.0"}] 19 | end 20 | ``` 21 | 22 | This module expects three application configuration settings for the 23 | AWS access and secret keys and the S3 bucket name. You may also supply 24 | an AWS region (the default if you do not is `us-east-1`). Here is an 25 | example configuration that reads these from environment variables. Add 26 | your own configuration to `config.exs`. 27 | 28 | ```elixir 29 | config :s3_direct_upload, 30 | aws_access_key: System.get_env("AWS_ACCESS_KEY_ID"), 31 | aws_secret_key: System.get_env("AWS_SECRET_ACCESS_KEY"), 32 | aws_s3_bucket: System.get_env("AWS_S3_BUCKET"), 33 | aws_region: System.get_env("AWS_REGION") 34 | 35 | ``` 36 | 37 | ## Documentation 38 | 39 | [S3DirectUpload docs](https://hexdocs.pm/s3_direct_upload). 40 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule S3DirectUpload.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :s3_direct_upload, 6 | version: "0.1.3", 7 | elixir: "~> 1.4", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | description: description(), 11 | package: package(), 12 | deps: deps()] 13 | end 14 | 15 | # Configuration for the OTP application 16 | def application do 17 | [extra_applications: [:logger]] 18 | end 19 | 20 | # Dependencies 21 | defp deps do 22 | [{:poison, "~> 2.0 or ~> 3.0"}, 23 | {:ex_doc, "~> 0.18", only: :dev, runtime: false}] 24 | end 25 | 26 | defp description do 27 | """ 28 | Pre-signed S3 upload helper for client-side multipart POSTs. 29 | """ 30 | end 31 | 32 | defp package do 33 | [ 34 | name: :s3_direct_upload, 35 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 36 | maintainers: ["Andrew Kappen"], 37 | licenses: ["Apache 2.0"], 38 | links: %{"GitHub" => "https://github.com/akappen/s3_direct_upload", 39 | "S3 Direct Uploads With Ember And Phoenix" => "http://haughtcodeworks.com/blog/software-development/s3-direct-uploads-with-ember-and-phoenix/", 40 | "Browser-Based Upload using HTTP POST (Using AWS Signature Version 4)" => "http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html", 41 | "Task 3: Calculate the Signature for AWS Signature Version 4" => "http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html"} 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/s3_direct_upload.ex: -------------------------------------------------------------------------------- 1 | defmodule S3DirectUpload do 2 | @moduledoc """ 3 | 4 | Pre-signed S3 upload helper for client-side multipart POSTs. 5 | 6 | See: 7 | 8 | [Browser-Based Upload using HTTP POST (Using AWS Signature Version 4)](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html) 9 | 10 | [Task 3: Calculate the Signature for AWS Signature Version 4](http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html) 11 | 12 | This module expects three application configuration settings for the 13 | AWS access and secret keys and the S3 bucket name. You may also 14 | supply an AWS region (the default if you do not is 15 | `us-east-1`). Here is an example configuration that reads these from 16 | environment variables. Add your own configuration to `config.exs`. 17 | 18 | ``` 19 | config :s3_direct_upload, 20 | aws_access_key: System.get_env("AWS_ACCESS_KEY_ID"), 21 | aws_secret_key: System.get_env("AWS_SECRET_ACCESS_KEY"), 22 | aws_s3_bucket: System.get_env("AWS_S3_BUCKET"), 23 | aws_region: System.get_env("AWS_REGION") 24 | ``` 25 | 26 | """ 27 | 28 | @doc """ 29 | 30 | The `S3DirectUpload` struct represents the data necessary to 31 | generate an S3 pre-signed upload object. 32 | 33 | The required fields are: 34 | 35 | - `file_name` the name of the file being uploaded 36 | - `mimetype` the mimetype of the file being uploaded 37 | - `path` the path where the file will be uploaded in the bucket 38 | 39 | Fields that can be over-ridden are: 40 | 41 | - `acl` defaults to `public-read` 42 | 43 | """ 44 | defstruct file_name: nil, mimetype: nil, path: nil, acl: "public-read" 45 | 46 | @date_util Application.get_env(:s3_direct_upload, :date_util, S3DirectUpload.DateUtil) 47 | 48 | @doc """ 49 | 50 | Returns a map with `url` and `credentials` keys. 51 | 52 | - `url` - the form action URL 53 | - `credentials` - name/value pairs for hidden input fields 54 | 55 | ## Examples 56 | 57 | iex> %S3DirectUpload{file_name: "image.jpg", mimetype: "image/jpeg", path: "path/to/file"} 58 | ...> |> S3DirectUpload.presigned 59 | ...> |> Map.get(:url) 60 | "https://s3-bucket.s3.amazonaws.com" 61 | 62 | iex> %S3DirectUpload{file_name: "image.jpg", mimetype: "image/jpeg", path: "path/to/file"} 63 | ...> |> S3DirectUpload.presigned 64 | ...> |> Map.get(:credentials) |> Map.get(:"x-amz-credential") 65 | "123abc/20170101/us-east-1/s3/aws4_request" 66 | 67 | iex> %S3DirectUpload{file_name: "image.jpg", mimetype: "image/jpeg", path: "path/to/file"} 68 | ...> |> S3DirectUpload.presigned 69 | ...> |> Map.get(:credentials) |> Map.get(:key) 70 | "path/to/file/image.jpg" 71 | 72 | """ 73 | def presigned(%S3DirectUpload{} = upload) do 74 | %{ 75 | url: "https://#{bucket()}.s3.amazonaws.com", 76 | credentials: %{ 77 | policy: policy(upload), 78 | "x-amz-algorithm": "AWS4-HMAC-SHA256", 79 | "x-amz-credential": credential(), 80 | "x-amz-date": @date_util.today_datetime(), 81 | "x-amz-signature": signature(upload), 82 | acl: upload.acl, 83 | key: file_path(upload) 84 | } 85 | } 86 | end 87 | 88 | @doc """ 89 | 90 | Returns a json object with `url` and `credentials` properties. 91 | 92 | - `url` - the form action URL 93 | - `credentials` - name/value pairs for hidden input fields 94 | 95 | """ 96 | def presigned_json(%S3DirectUpload{} = upload) do 97 | presigned(upload) 98 | |> Poison.encode! 99 | end 100 | 101 | defp signature(upload) do 102 | signing_key() 103 | |> hmac(policy(upload)) 104 | |> Base.encode16(case: :lower) 105 | end 106 | 107 | defp signing_key do 108 | "AWS4#{secret_key()}" 109 | |> hmac(@date_util.today_date()) 110 | |> hmac(region()) 111 | |> hmac("s3") 112 | |> hmac("aws4_request") 113 | end 114 | 115 | defp policy(upload) do 116 | %{ 117 | expiration: @date_util.expiration_datetime, 118 | conditions: conditions(upload) 119 | } 120 | |> Poison.encode! 121 | |> Base.encode64 122 | end 123 | 124 | defp conditions(upload) do 125 | [ 126 | %{"bucket" => bucket()}, 127 | %{"acl" => upload.acl}, 128 | %{"x-amz-algorithm": "AWS4-HMAC-SHA256"}, 129 | %{"x-amz-credential": credential()}, 130 | %{"x-amz-date": @date_util.today_datetime()}, 131 | ["starts-with", "$Content-Type", upload.mimetype], 132 | ["starts-with", "$key", upload.path] 133 | ] 134 | end 135 | 136 | defp credential() do 137 | "#{access_key()}/#{@date_util.today_date()}/#{region()}/s3/aws4_request" 138 | end 139 | 140 | defp file_path(upload) do 141 | "#{upload.path}/#{upload.file_name}" 142 | end 143 | 144 | defp hmac(key, data) do 145 | :crypto.hmac(:sha256, key, data) 146 | end 147 | 148 | defp access_key, do: Application.get_env(:s3_direct_upload, :aws_access_key) 149 | defp secret_key, do: Application.get_env(:s3_direct_upload, :aws_secret_key) 150 | defp bucket, do: Application.get_env(:s3_direct_upload, :aws_s3_bucket) 151 | defp region, do: Application.get_env(:s3_direct_upload, :aws_region) || "us-east-1" 152 | end 153 | --------------------------------------------------------------------------------