Packages
phoenix_kit
2.13.1
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/storage/providers/s3.ex
defmodule PhoenixKit.Modules.Storage.Providers.S3 do
@moduledoc """
AWS S3 storage provider.
Stores files in Amazon S3 buckets using the ExAWS library.
Supports all S3-compatible services (like Backblaze B2, Cloudflare R2, Tigris).
Files under 5 MB are uploaded via `put_object` (single request).
Files at or above 5 MB use multipart upload via `ExAws.S3.upload/4`
with streaming and concurrent chunk uploads.
"""
require Logger
alias ExAws.S3.Upload
alias PhoenixKit.Integrations
alias PhoenixKit.Integrations.Encryption
@behaviour PhoenixKit.Modules.Storage.Provider
# Files at or above this size use multipart upload
@multipart_threshold 5 * 1024 * 1024
@impl true
def store_file(bucket, source_path, destination_path, opts \\ []) do
case File.stat(source_path) do
{:ok, %{size: size}} when size >= @multipart_threshold ->
multipart_upload(bucket, source_path, destination_path, opts)
{:ok, _stat} ->
simple_upload(bucket, source_path, destination_path, opts)
{:error, reason} ->
Logger.error("S3 upload: cannot access source file #{source_path}: #{inspect(reason)}")
{:error, "Cannot access source file: #{inspect(reason)}"}
end
rescue
error ->
Logger.error("S3 upload exception for #{bucket.name}: #{Exception.message(error)}")
{:error, "Error storing file to S3: #{inspect(error)}"}
end
@impl true
def retrieve_file(bucket, file_path, destination_path) do
destination_dir = Path.dirname(destination_path)
File.mkdir_p!(destination_dir)
case ExAws.S3.download_file(bucket.bucket_name, file_path, destination_path)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} -> :ok
{:error, reason} -> {:error, "Failed to download from S3: #{inspect(reason)}"}
end
rescue
error -> {:error, "Error retrieving file from S3: #{inspect(error)}"}
end
@impl true
def delete_file(bucket, file_path) do
case ExAws.S3.delete_object(bucket.bucket_name, file_path)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} -> :ok
{:error, reason} -> {:error, "Failed to delete from S3: #{inspect(reason)}"}
end
rescue
error -> {:error, "Error deleting file from S3: #{inspect(error)}"}
end
@impl true
def file_exists?(bucket, file_path) do
case ExAws.S3.head_object(bucket.bucket_name, file_path)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} -> true
{:error, {:http_error, 404, _}} -> false
{:error, _reason} -> false
end
rescue
_error -> false
end
@impl true
def public_url(bucket, file_path) do
if bucket.cdn_url do
"#{bucket.cdn_url}/#{file_path}"
else
region = bucket.region || "us-east-1"
"https://#{bucket.bucket_name}.s3.#{region}.amazonaws.com/#{file_path}"
end
end
@impl true
def test_connection(bucket) do
case ExAws.S3.list_objects(bucket.bucket_name, max_keys: 1)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} -> :ok
{:error, {:http_error, 403, _}} -> {:error, "Access denied - check permissions"}
{:error, {:http_error, 404, _}} -> {:error, "Bucket not found"}
{:error, reason} -> {:error, "S3 connection test failed: #{inspect(reason)}"}
end
rescue
error -> {:error, "Error testing S3 connection: #{inspect(error)}"}
end
# Single-request upload for small files (<5 MB).
# Reads entire file into memory and sends in one PUT request.
defp simple_upload(bucket, source_path, destination_path, opts) do
content_type = Keyword.get(opts, :content_type)
case File.read(source_path) do
{:ok, file_content} ->
put_opts =
[{:acl, Keyword.get(opts, :acl, "private")}] ++
if(content_type, do: [{:content_type, content_type}], else: [])
case ExAws.S3.put_object(bucket.bucket_name, destination_path, file_content, put_opts)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} ->
{:ok, public_url(bucket, destination_path)}
{:error, reason} ->
Logger.error("S3 put_object failed for #{bucket.name}: #{inspect(reason)}")
{:error, "Failed to upload to S3: #{inspect(reason)}"}
end
{:error, reason} ->
Logger.error("S3 upload: cannot read source file #{source_path}: #{inspect(reason)}")
{:error, "Cannot read source file: #{inspect(reason)}"}
end
end
# Multipart streaming upload for large files (>=5 MB).
# Streams file in chunks with concurrent part uploads.
defp multipart_upload(bucket, source_path, destination_path, opts) do
content_type = Keyword.get(opts, :content_type)
upload_opts =
[acl: Keyword.get(opts, :acl, "private"), max_concurrency: 4, timeout: 60_000] ++
if(content_type, do: [content_type: content_type], else: [])
case source_path
|> Upload.stream_file()
|> ExAws.S3.upload(bucket.bucket_name, destination_path, upload_opts)
|> ExAws.request(aws_config(bucket)) do
{:ok, _result} ->
{:ok, public_url(bucket, destination_path)}
{:error, reason} ->
Logger.error("S3 multipart upload failed for #{bucket.name}: #{inspect(reason)}")
{:error, "Failed multipart upload to S3: #{inspect(reason)}"}
end
end
# Build per-request ExAws config from bucket credentials.
# Passed to ExAws.request/2 instead of using global Application.put_env.
defp aws_config(bucket) do
{access_key_id, secret_access_key} = resolve_credentials(bucket)
config = [
access_key_id: access_key_id,
secret_access_key: secret_access_key,
region: bucket.region || "us-east-1"
]
if bucket.endpoint do
config ++ [host: bucket.endpoint, scheme: "https://"]
else
config
end
end
# Resolves the actual (plaintext) access key id / secret access key for a
# bucket — the one place this happens, right where the ExAws config needs
# them. `Bucket.changeset/2` guarantees only one of the two credential
# sources below is ever set on a saved bucket.
#
# Every failure path here returns {nil, nil} / a nil secret rather than
# raising — a bad/expired credential should fail as an ExAws auth error on
# the actual request, not crash the caller. Each path logs why first,
# naming the bucket and the failure REASON only, never a credential value.
#
# Public and `@doc false` (not part of the `Provider` behaviour) purely so
# the test suite can exercise both branches directly, without a real S3
# endpoint — same rationale as `V174.repair_statements/1`.
@doc false
@spec resolve_credentials(PhoenixKit.Modules.Storage.Bucket.t()) ::
{String.t() | nil, String.t() | nil}
def resolve_credentials(%{integration_uuid: integration_uuid} = bucket)
when is_binary(integration_uuid) and integration_uuid != "" do
case Integrations.get_credentials(integration_uuid) do
{:ok, creds} ->
# "access_key"/"secret_key" is the generic key-secret shape
# `PhoenixKit.Integrations` providers use for AWS-style credentials
# (see `aws_ses`, and `PhoenixKit.Mailer.swoosh_config_for/1`) — the
# `object_storage` provider this bucket-side integration_uuid exists
# for (`PhoenixKit.Integrations.Providers.object_storage/0`, added in
# a parallel branch) declares the same two field keys.
access_key = creds["access_key"]
secret_key = creds["secret_key"]
if is_binary(access_key) and access_key != "" and is_binary(secret_key) and
secret_key != "" do
{access_key, secret_key}
else
Logger.error(
"S3 bucket #{bucket.name}: integration #{integration_uuid} has no " <>
"access_key/secret_key configured"
)
{nil, nil}
end
{:error, reason} ->
Logger.error(
"S3 bucket #{bucket.name}: failed to resolve credentials from integration " <>
"#{integration_uuid}: #{inspect(reason)}"
)
{nil, nil}
end
end
def resolve_credentials(bucket) do
secret =
case Encryption.decrypt_value(bucket.secret_access_key) do
{:ok, plaintext} ->
plaintext
{:error, reason} ->
Logger.error(
"S3 bucket #{bucket.name}: failed to decrypt secret_access_key: #{inspect(reason)}"
)
nil
end
{bucket.access_key_id, secret}
end
end