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/schemas/bucket.ex
defmodule PhoenixKit.Modules.Storage.Bucket do
@moduledoc """
Schema for storage provider configurations.
Buckets represent storage locations where files can be stored. They can be:
- **Local** filesystem storage
- **AWS S3** buckets
- **Backblaze B2** buckets
- **Cloudflare R2** buckets
## Priority System
- `priority = 0` (default): Random selection, prefer most empty drive
- `priority > 0`: Specific priority (1 = highest, 2 = second, etc.)
## Fields
- `name` - Display name for the bucket
- `provider` - Storage provider: "local", "s3", "b2", "r2"
- `region` - AWS region or equivalent (nullable)
- `endpoint` - Custom S3-compatible endpoint (nullable)
- `bucket_name` - S3 bucket name (nullable)
- `access_key_id` - Credentials identifier (nullable, stored as-is — not a secret)
- `secret_access_key` - Encrypted at rest via `PhoenixKit.Integrations.Encryption`
(nullable); decrypted only at the point of use (e.g. `Providers.S3`'s AWS
config builder), never by a general accessor like `Storage.get_bucket/1`.
A value already in this column when encryption was added is migrated
opportunistically, not by a bulk backfill: the changeset re-derives and
re-encrypts it on the next save of the bucket for ANY reason (see
`encrypt_secret_access_key/1`), so it stays plaintext only until then
- `integration_uuid` - Alternative credential source: a `PhoenixKit.Integrations`
connection uuid (nullable, no FK). Mutually exclusive with
`access_key_id`/`secret_access_key` — a changeset may set one source or the
other, never both
- `cdn_url` - CDN endpoint for file serving (nullable)
- `access_type` - How files are served: "public", "private", "signed" (default: "public")
- `enabled` - Whether bucket is active
- `priority` - Selection priority (0 = random/emptiest)
- `max_size_mb` - Maximum storage capacity in MB (nullable = unlimited)
## Access Types
- `public` - Redirect to public URL (default, fastest, uses CDN)
- `private` - Proxy files through server (for ACL-protected buckets)
- `signed` - Use presigned URLs (future implementation)
## Examples
# Local storage bucket
%Bucket{
name: "Local SSD",
provider: "local",
enabled: true,
priority: 0,
max_size_mb: 512_000 # 500 GB
}
# AWS S3 bucket
%Bucket{
name: "Production S3",
provider: "s3",
region: "us-east-1",
bucket_name: "my-app-files",
access_key_id: "AKIA...",
secret_access_key: "...",
cdn_url: "https://cdn.example.com",
enabled: true,
priority: 1 # Highest priority
}
# Backblaze B2 bucket
%Bucket{
name: "Backup B2",
provider: "b2",
endpoint: "s3.us-west-002.backblazeb2.com",
bucket_name: "my-backup-bucket",
access_key_id: "...",
secret_access_key: "...",
enabled: true,
priority: 2
}
"""
use Ecto.Schema
use PhoenixKit.SchemaPrefix
import Ecto.Changeset
alias PhoenixKit.Integrations.Encryption
@primary_key {:uuid, UUIDv7, autogenerate: true}
@foreign_key_type UUIDv7
@type t :: %__MODULE__{
uuid: UUIDv7.t() | nil,
name: String.t(),
provider: String.t(),
region: String.t() | nil,
endpoint: String.t() | nil,
bucket_name: String.t() | nil,
access_key_id: String.t() | nil,
secret_access_key: String.t() | nil,
integration_uuid: UUIDv7.t() | nil,
cdn_url: String.t() | nil,
access_type: String.t(),
enabled: boolean(),
priority: integer(),
max_size_mb: integer() | nil,
file_locations:
[PhoenixKit.Modules.Storage.FileLocation.t()] | Ecto.Association.NotLoaded.t(),
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
schema "phoenix_kit_buckets" do
field :name, :string
field :provider, :string
field :region, :string
field :endpoint, :string
field :bucket_name, :string
field :access_key_id, :string
field :secret_access_key, :string, redact: true
field :integration_uuid, UUIDv7
field :cdn_url, :string
field :access_type, :string, default: "public"
field :enabled, :boolean, default: true
field :priority, :integer, default: 0
field :max_size_mb, :integer
has_many :file_locations, PhoenixKit.Modules.Storage.FileLocation, foreign_key: :bucket_uuid
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a bucket.
## Required Fields
- `name`
- `provider` (must be one of: "local", "s3", "b2", "r2")
## Validation Rules
- Provider must be valid
- Priority must be >= 0
- Quality must be between 1-100 (if provided)
- S3/B2/R2 buckets require credentials — either `access_key_id`/
`secret_access_key` directly, or an `integration_uuid`
- `integration_uuid` and `access_key_id`/`secret_access_key` are mutually
exclusive: setting both in the same change is a validation error, not a
silent overwrite of one by the other
`secret_access_key` is encrypted at rest (see
`PhoenixKit.Integrations.Encryption`) as part of this changeset —
already-encrypted values pass through unchanged. A row written before
encryption existed stays plaintext until the next save of any kind (no
bulk backfill — see the `secret_access_key` field doc above).
"""
def changeset(bucket, attrs) do
bucket
|> cast(attrs, [
:name,
:provider,
:region,
:endpoint,
:bucket_name,
:access_key_id,
:secret_access_key,
:integration_uuid,
:cdn_url,
:access_type,
:enabled,
:priority,
:max_size_mb
])
|> validate_required([:name, :provider])
|> validate_inclusion(:provider, ["local", "s3", "b2", "r2", "tigris"])
|> validate_inclusion(:access_type, ["public", "private", "signed"])
|> validate_number(:priority, greater_than_or_equal_to: 0)
|> validate_number(:max_size_mb, greater_than: 0)
|> validate_credentials_exclusive()
|> validate_cloud_credentials()
|> encrypt_secret_access_key()
end
# A bucket may resolve its cloud credentials from ITS OWN
# access_key_id/secret_access_key OR from an Integrations connection —
# never both. Two independently-writable sources for the same secret is
# exactly the silent-drift shape this fix exists to close, so a change
# that sets both is rejected outright rather than letting one field win.
defp validate_credentials_exclusive(changeset) do
has_integration = present?(get_field(changeset, :integration_uuid))
has_direct_keys =
present?(get_field(changeset, :access_key_id)) or
present?(get_field(changeset, :secret_access_key))
if has_integration and has_direct_keys do
add_error(
changeset,
:integration_uuid,
"clear access_key_id and secret_access_key before setting integration_uuid " <>
"(or clear integration_uuid to use direct credentials instead) — only one " <>
"credential source at a time"
)
else
changeset
end
end
defp validate_cloud_credentials(changeset) do
provider = get_field(changeset, :provider)
if provider in ["s3", "b2", "r2"] do
changeset
|> validate_required([:bucket_name])
|> validate_credentials_present()
else
changeset
end
end
defp validate_credentials_present(changeset) do
if present?(get_field(changeset, :integration_uuid)) do
changeset
else
validate_required(changeset, [:access_key_id, :secret_access_key])
end
end
defp encrypt_secret_access_key(changeset) do
case get_field(changeset, :secret_access_key) do
nil -> changeset
"" -> changeset
value -> put_change(changeset, :secret_access_key, Encryption.encrypt_value(value))
end
end
defp present?(nil), do: false
defp present?(""), do: false
defp present?(_value), do: true
@doc """
Returns whether this bucket is a local storage bucket.
"""
def local?(%__MODULE__{provider: "local"}), do: true
def local?(_), do: false
@doc """
Returns whether this bucket is a cloud storage bucket (S3, B2, R2).
"""
def cloud?(%__MODULE__{provider: provider}) when provider in ["s3", "b2", "r2"], do: true
def cloud?(_), do: false
end