Packages
phoenix_contrib_storage
0.1.0
ActiveStorage-like file storage for Phoenix. All things file uploads for your Phoenix app.
Current section
Files
Jump to
Current section
Files
lib/storage/services/s3.ex
defmodule Storage.Services.S3 do
@moduledoc """
Amazon S3 storage service implementation.
Requires the following dependencies to be added to your mix.exs:
{:ex_aws, "~> 2.4"},
{:ex_aws_s3, "~> 2.4"},
{:hackney, "~> 1.18"},
{:sweet_xml, "~> 0.7"}
## Configuration
config :phoenix_contrib_storage,
services: %{
s3: {Storage.Services.S3,
bucket: "my-bucket",
region: "us-east-1",
access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"}
}
}
"""
@behaviour Storage.Services
@impl Storage.Services
def put_file(key, data, config) do
bucket = get_config_value(config, :bucket)
file_data = normalize_data(data)
case ExAws.S3.put_object(bucket, key, file_data)
|> ExAws.request(aws_config(config)) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
@impl Storage.Services
def get_file(key, config) do
bucket = get_config_value(config, :bucket)
case ExAws.S3.get_object(bucket, key)
|> ExAws.request(aws_config(config)) do
{:ok, %{body: body}} -> {:ok, body}
{:error, reason} -> {:error, reason}
end
end
@impl Storage.Services
def delete_file(key, config) do
bucket = get_config_value(config, :bucket)
case ExAws.S3.delete_object(bucket, key)
|> ExAws.request(aws_config(config)) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
@impl Storage.Services
def url(key, config, opts \\ []) do
bucket = get_config_value(config, :bucket)
region = get_config_value(config, :region)
case Keyword.get(opts, :signed, false) do
true ->
case signed_url(key, config, opts) do
{:ok, url} -> url
{:error, _} -> public_url(bucket, region, key)
end
false ->
public_url(bucket, region, key)
end
end
@impl Storage.Services
def signed_url(key, config, opts \\ []) do
bucket = get_config_value(config, :bucket)
expires_in = Keyword.get(opts, :expires_in, 3600) # 1 hour default
case ExAws.S3.presigned_url(aws_config(config), :get, bucket, key, expires_in: expires_in) do
{:ok, url} -> {:ok, url}
{:error, reason} -> {:error, reason}
end
end
@doc """
Generates a signed URL for direct uploads.
"""
def signed_upload_url(key, config, opts \\ []) do
bucket = get_config_value(config, :bucket)
expires_in = Keyword.get(opts, :expires_in, 3600)
content_type = Keyword.get(opts, :content_type, "application/octet-stream")
conditions = [
["starts-with", "$Content-Type", ""],
["content-length-range", 0, Keyword.get(opts, :max_file_size, 100_000_000)]
]
case ExAws.S3.presigned_post(aws_config(config), bucket,
key: key,
expires_in: expires_in,
content_type: content_type,
conditions: conditions
) do
{:ok, presigned_data} -> {:ok, presigned_data}
{:error, reason} -> {:error, reason}
end
end
@doc """
Lists objects in the bucket with optional prefix.
"""
def list_objects(config, opts \\ []) do
bucket = get_config_value(config, :bucket)
prefix = Keyword.get(opts, :prefix, "")
max_keys = Keyword.get(opts, :max_keys, 1000)
case ExAws.S3.list_objects_v2(bucket, prefix: prefix, max_keys: max_keys)
|> ExAws.request(aws_config(config)) do
{:ok, %{body: %{contents: contents}}} ->
{:ok, Enum.map(contents, &map_s3_object/1)}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Checks if an object exists in S3.
"""
def exists?(key, config) do
bucket = get_config_value(config, :bucket)
case ExAws.S3.head_object(bucket, key)
|> ExAws.request(aws_config(config)) do
{:ok, _} -> true
{:error, _} -> false
end
end
@doc """
Gets object metadata without downloading the file.
"""
def head_object(key, config) do
bucket = get_config_value(config, :bucket)
case ExAws.S3.head_object(bucket, key)
|> ExAws.request(aws_config(config)) do
{:ok, response} -> {:ok, response}
{:error, reason} -> {:error, reason}
end
end
# Private functions
defp normalize_data(data) when is_binary(data), do: data
defp normalize_data(%{path: path}) do
File.read!(path)
end
defp aws_config(config) do
base_config = [
access_key_id: get_config_value(config, :access_key_id),
secret_access_key: get_config_value(config, :secret_access_key),
region: get_config_value(config, :region)
]
# Add optional configurations
config
|> Keyword.take([:scheme, :host, :port, :http_client])
|> Keyword.merge(base_config)
end
defp get_config_value(config, key) do
case Keyword.get(config, key) do
{:system, env_var} -> System.get_env(env_var)
value -> value
end
end
defp public_url(bucket, region, key) do
"https://#{bucket}.s3.#{region}.amazonaws.com/#{key}"
end
defp map_s3_object(object) do
%{
key: object.key,
last_modified: object.last_modified,
etag: object.etag,
size: object.size,
storage_class: object.storage_class
}
end
end