defmodule Attached do
@moduledoc """
File attachments for Ecto schemas.
## Changeset integration
Schemas that `use Attached.Ecto.Schema` automatically import `put_attached/3`
for use inside their `changeset/2`:
def changeset(user, attrs) do
user
|> cast(attrs, [:name])
|> put_attached(:avatar, attrs["avatar"])
end
See `Attached.Ecto.Changeset` for details.
## Querying
Attached.url(user, :avatar)
Attached.url(user, :avatar, :thumb)
Attached.attached?(user, :avatar)
## Preloading
User |> Attached.with_attached(:avatar) |> Repo.all()
"""
require Ecto.Query
# -------------------------------------------------------------------
# Querying
# -------------------------------------------------------------------
@doc """
Returns the URL for an attachment, optionally for a named variant.
Attached.url(user, :avatar)
Attached.url(user, :avatar, :thumb)
Returns `nil` if no file is attached.
When called with a variant name, `:variants` must be preloaded on the
original — either via `Attached.with_attached/2` (recommended) or
`Repo.preload(record, avatar_attached_original: :variants)`. Raises `ArgumentError`
otherwise.
"""
def url(record, field, variant \\ nil)
def url(record, field, nil) do
case get_original(record, field) do
nil -> nil
original -> signed_url(original.key)
end
end
def url(record, field, variant_name) do
case get_original(record, field) do
nil ->
nil
original ->
transforms =
record
|> Attached.Variants.transforms_for(field, variant_name)
|> Keyword.put(:variant_name, variant_name)
transform_digest = Attached.Variants.transform_digest(transforms)
variants =
case original.variants do
%Ecto.Association.NotLoaded{} ->
raise ArgumentError,
"Attached.url/3 with a variant name requires :variants to be preloaded on the original. " <>
"Use `Attached.with_attached(query, #{inspect(field)})` or " <>
"`Repo.preload(record, #{inspect(:"#{field}_attached_original")}: :variants)`."
list when is_list(list) ->
list
end
variant =
Enum.find(variants, &(&1.transform_digest == transform_digest)) ||
Attached.Variants.process(original, transform_digest, transforms)
signed_url(Attached.Variants.path_for(original, variant))
end
end
@doc """
Returns `true` if the record has a file attached for the given field.
"""
def attached?(record, field) do
schema = record.__struct__
case schema.__attached_config__(field) do
{_, opts} ->
fk = Keyword.fetch!(opts, :foreign_key)
not is_nil(Map.get(record, fk))
nil ->
false
end
end
@doc """
Returns an Ecto query with the attachment original preloaded.
User |> Attached.with_attached(:avatar) |> Repo.all()
"""
def with_attached(queryable, field) do
Ecto.Query.preload(queryable, [{^:"#{field}_attached_original", :variants}])
end
# -------------------------------------------------------------------
# Purging
# -------------------------------------------------------------------
@doc """
Synchronously deletes the attachment: removes the original record,
variant records, variant files, and all files from storage.
"""
def purge(record, field) do
case get_original(record, field) do
nil ->
:ok
original ->
fk = one_fk(record.__struct__, field)
repo = Attached.Repo.current()
record
|> Ecto.Changeset.change(%{fk => nil})
|> repo.update!()
Attached.Originals.purge!(original)
end
end
@doc """
Enqueues an Oban job to purge the attachment asynchronously.
"""
def purge_later(record, field) do
case get_original(record, field) do
nil -> {:ok, :noop}
original -> Attached.Originals.purge_later(original)
end
end
# -------------------------------------------------------------------
# Standalone original upload
# -------------------------------------------------------------------
@doc """
Creates an original and uploads the file to storage without a schema attachment context.
Useful for endpoints that accept file uploads independently of a parent record
(e.g. Trix inline image uploads before an article is saved).
Options:
* `:owner_table` (required) — the table name the original will eventually belong to
* `:owner_field` (required) — the FK column name (e.g. `"attached_original_id"`)
Returns the inserted `%Attached.Originals.Original{}`.
"""
def upload_original(upload, opts) do
Attached.Originals.create_from_upload!(upload, opts)
end
@doc """
Returns a signed URL for a storage key.
Useful when you have an original key and need a URL without going through
`url/2` or `url/3` (which require a loaded schema record).
Returns `{:ok, url}` on success, or `{:error, reason}` if the storage
backend cannot produce a URL (misconfigured backend, missing
credentials, signing failure, etc.).
"""
def original_url(key) when is_binary(key) do
{:ok, signed_url(key)}
rescue
e -> {:error, e}
end
# -------------------------------------------------------------------
# Internals
# -------------------------------------------------------------------
defp get_original(record, field), do: Map.get(record, :"#{field}_attached_original")
defp one_fk(schema, field) do
{_, opts} = schema.__attached_config__(field)
Keyword.fetch!(opts, :foreign_key)
end
defp signed_url(key) do
Attached.StorageBackends.url(Attached.Web.Signer.sign(key))
end
end