defmodule Waffle.Storage.S3 do
@moduledoc ~S"""
The module to facilitate integration with S3 through ExAws.S3
config :waffle,
storage: Waffle.Storage.S3,
bucket: {:system, "AWS_S3_BUCKET"}
Along with any configuration necessary for ExAws.
[ExAws](https://github.com/CargoSense/ex_aws) is used to support Amazon S3.
To store your attachments in Amazon S3, you'll need to provide a
bucket destination in your application config:
config :waffle,
bucket: "uploads"
You may also set the bucket from an environment variable:
config :waffle,
bucket: {:system, "S3_BUCKET"}
In addition, ExAws must be configured with the appropriate Amazon S3
credentials.
ExAws has by default the following configuration (which you may
override if you wish):
config :ex_aws,
json_codec: Jason,
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role]
This means it will first look for the AWS standard
`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment
variables, and fall back using instance meta-data if those don't
exist. You should set those environment variables to your
credentials, or configure an instance that this library runs on to
have an iam role.
## Specify multiple buckets
Waffle lets you specify a bucket on a per definition basis. In case
you want to use multiple buckets, you can specify a bucket in the
definition module like this:
def bucket, do: :some_custom_bucket_name
You can also use the current scope to define a target bucket
def bucket({_file, scope}), do: scope.bucket || bucket()
## Access Control Permissions
Waffle defaults all uploads to `private`. In cases where it is
desired to have your uploads public, you may set the ACL at the
module level (which applies to all versions):
@acl :public_read
Or you may have more granular control over each version. As an
example, you may wish to explicitly only make public a thumbnail
version of the file:
def acl(:thumb, _), do: :public_read
Supported access control lists for Amazon S3 are:
| ACL | Permissions Added to ACL |
|------------------------------|---------------------------------------------------------------------------------|
| `:private` | Owner gets `FULL_CONTROL`. No one else has access rights (default). |
| `:public_read` | Owner gets `FULL_CONTROL`. The `AllUsers` group gets READ access. |
| `:public_read_write` | Owner gets `FULL_CONTROL`. The `AllUsers` group gets `READ` and `WRITE` access. |
| | Granting this on a bucket is generally not recommended. |
| `:authenticated_read` | Owner gets `FULL_CONTROL`. The `AuthenticatedUsers` group gets `READ` access. |
| `:bucket_owner_read` | Object owner gets `FULL_CONTROL`. Bucket owner gets `READ` access. |
| `:bucket_owner_full_control` | Both the object owner and the bucket owner get `FULL_CONTROL` over the object. |
For more information on the behavior of each of these, please
consult Amazon's documentation for [Access Control List (ACL)
Overview](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html).
## S3 Object Headers
The definition module may specify custom headers to pass through to
S3 during object creation. The available custom headers include:
* `:cache_control`
* `:content_disposition`
* `:content_encoding`
* `:content_length`
* `:content_type`
* `:expect`
* `:expires`
* `:storage_class`
* `:website_redirect_location`
* `:encryption` (set to "AES256" for encryption at rest)
As an example, to explicitly specify the content-type of an object,
you may define a `s3_object_headers/2` function in your definition,
which returns a Keyword list, or Map of desired headers.
def s3_object_headers(version, {file, scope}) do
[content_type: MIME.from_path(file.file_name)] # for "image.png", would produce: "image/png"
end
## Alternate S3 configuration example
If you are using a region other than US-Standard, it is necessary to
specify the correct configuration for `ex_aws`. A full example
configuration for both waffle and ex_aws is as follows:
config :waffle,
bucket: "my-frankfurt-bucket"
config :ex_aws,
json_codec: Jason,
access_key_id: "my_access_key_id",
secret_access_key: "my_secret_access_key",
region: "eu-central-1",
s3: [
scheme: "https://",
host: "s3.eu-central-1.amazonaws.com",
region: "eu-central-1"
]
> For your host configuration, please examine the approved [AWS Hostnames](http://docs.aws.amazon.com/general/latest/gr/rande.html). There are often multiple hostname formats for AWS regions, and it will not work unless you specify the correct one.
"""
require Logger
alias ExAws.Config
alias ExAws.Request.Url
alias ExAws.S3
alias ExAws.S3.Upload
alias Waffle.Definition.Versioning
@default_expiry_time 60 * 5
def put(definition, version, {file, scope}) do
destination_dir = definition.storage_dir(version, {file, scope})
s3_bucket = s3_bucket(definition, {file, scope})
s3_key = Path.join(destination_dir, file.file_name)
acl = definition.acl(version, {file, scope})
s3_options =
definition.s3_object_headers(version, {file, scope})
|> ensure_keyword_list()
|> Keyword.put(:acl, acl)
do_put(file, {s3_bucket, s3_key, s3_options})
end
def url(definition, version, file_and_scope, options \\ []) do
case Keyword.get(options, :signed, false) do
false -> build_url(definition, version, file_and_scope, options)
true -> build_signed_url(definition, version, file_and_scope, options)
end
end
def delete(definition, version, {file, scope}) do
s3_bucket(definition, {file, scope})
|> S3.delete_object(s3_key(definition, version, {file, scope}))
|> ExAws.request()
:ok
end
#
# Private
#
defp ensure_keyword_list(list) when is_list(list), do: list
defp ensure_keyword_list(map) when is_map(map), do: Map.to_list(map)
# If the file is stored as a binary in-memory, send to AWS in a single request
defp do_put(file = %Waffle.File{binary: file_binary}, {s3_bucket, s3_key, s3_options})
when is_binary(file_binary) do
S3.put_object(s3_bucket, s3_key, file_binary, s3_options)
|> ExAws.request()
|> case do
{:ok, _res} -> {:ok, file.file_name}
{:error, error} -> {:error, error}
end
end
# Stream the file and upload to AWS as a multi-part upload
defp do_put(file, {s3_bucket, s3_key, s3_options}) do
file.path
|> Upload.stream_file()
|> S3.upload(s3_bucket, s3_key, s3_options)
|> ExAws.request()
|> case do
{:ok, %{status_code: 200}} -> {:ok, file.file_name}
{:ok, :done} -> {:ok, file.file_name}
{:error, error} -> {:error, error}
end
rescue
e in ExAws.Error ->
Logger.error(inspect(e))
Logger.error(e.message)
{:error, :invalid_bucket}
end
defp build_url(definition, version, file_and_scope, _options) do
asset_path =
s3_key(definition, version, file_and_scope)
|> Url.sanitize(:s3)
Path.join(host(definition, file_and_scope), asset_path)
end
defp build_signed_url(definition, version, file_and_scope, options) do
# Previous waffle argument was expire_in instead of expires_in
# check for expires_in, if not present, use expire_at.
options = put_in(options[:expires_in], Keyword.get(options, :expires_in, options[:expire_in]))
# fallback to default, if neither is present.
options = put_in(options[:expires_in], options[:expires_in] || @default_expiry_time)
options = put_in(options[:virtual_host], virtual_host())
config = Config.new(:s3, Application.get_all_env(:ex_aws))
s3_key = s3_key(definition, version, file_and_scope)
s3_bucket = s3_bucket(definition, file_and_scope)
{:ok, url} = S3.presigned_url(config, :get, s3_bucket, s3_key, options)
url
end
defp s3_key(definition, version, file_and_scope) do
Path.join([
definition.storage_dir(version, file_and_scope),
Versioning.resolve_file_name(definition, version, file_and_scope)
])
end
defp host(definition, file_and_scope) do
case asset_host(definition, file_and_scope) do
{:system, env_var} when is_binary(env_var) -> System.get_env(env_var)
url -> url
end
end
defp asset_host(definition, file_and_scope) do
case definition.asset_host() do
false -> default_host(definition, file_and_scope)
nil -> default_host(definition, file_and_scope)
host -> host
end
end
defp default_host(definition, file_and_scope) do
case virtual_host() do
true -> "https://#{s3_bucket(definition, file_and_scope)}.s3.amazonaws.com"
_ -> "https://s3.amazonaws.com/#{s3_bucket(definition, file_and_scope)}"
end
end
defp virtual_host do
Application.get_env(:waffle, :virtual_host) || false
end
defp s3_bucket(definition, file_and_scope) do
definition.bucket(file_and_scope) |> parse_bucket()
end
defp parse_bucket({:system, env_var}) when is_binary(env_var), do: System.get_env(env_var)
defp parse_bucket(name), do: name
end