Skip to main content

lib/attached.ex

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