lib/dropkick/file.ex

defmodule Dropkick.File do
  @moduledoc """
  A custom type that maps a upload-like structure to a file.

    * `:key` - The location of the the uploaded file
    * `:content_type` - The content type of the uploaded file
    * `:filename` - The filename of the uploaded file given in the request
    * `:status` - The status of the uploaded file. It can be `:cached` (when the file
    was just casted from a `Plug.Upload` and the key points to a temporary directory), `:deleted` (when
    the file was deleted from the storage and the key points to its old location) and `:stored` (when the
    file was persisted to its final destination and the key points to its current location).
    * `:metadata` - This field is meant to be used by users to store metadata about the file.
    * `:__cache__` - This field is used to store internal in-memmory information about the file that might
    be relevant during processing (this field is currently not used internally and its not casted to the database).

  ## Security

  Like `Plug.Upload`, the `:content_type` and `:filename` fields are client-controlled.
  Because of this, you should inspect and validate these values before trusting the upload content.
  You can check the file's [magic number](https://en.wikipedia.org/wiki/Magic_number_(programming)) signature
  by passing the option `infer: true` to your fields:

      field :avatar, Dropkick.File, infer: true
  """
  @derive {Inspect, optional: [:metadata], except: [:__cache__]}
  @derive {Jason.Encoder, except: [:__cache__]}
  @enforce_keys [:key, :status, :filename, :content_type]
  defstruct [:__cache__, :key, :status, :filename, :content_type, :metadata]

  use Ecto.ParameterizedType

  @impl true
  def type(_params), do: :map

  @impl true
  def init(opts), do: Enum.into(opts, %{})

  @impl true
  def cast(nil, _params), do: {:ok, nil}

  def cast(%__MODULE__{} = file, _params), do: {:ok, file}

  def cast(%{filename: filename, path: path, content_type: content_type}, params) do
    case Map.get(params, :infer, false) && Infer.get_from_path(path) do
      nil ->
        {:error, "File might be invalid. Could not infer file type information"}

      false ->
        {:ok,
         %__MODULE__{
           key: path,
           status: :cached,
           filename: filename,
           content_type: content_type
         }}

      %{extension: ext, mime_type: type} ->
        filename = Path.basename(filename, Path.extname(filename))

        {:ok,
         %__MODULE__{
           key: path,
           status: :cached,
           filename: "#{filename}.#{ext}",
           content_type: type
         }}
    end
  end

  def cast(_data, _params), do: :error

  @impl true
  def load(nil, _loader, _params), do: {:ok, nil}

  def load(data, _loader, _params) when is_map(data) do
    data =
      Enum.map(data, fn
        {"status", v} ->
          {:status, String.to_atom(v)}

        {k, v} ->
          {String.to_existing_atom(k), v}
      end)

    {:ok, struct!(__MODULE__, data)}
  end

  @impl true
  def dump(nil, _dumper, _params), do: {:ok, nil}
  def dump(%__MODULE__{} = file, _dumper, _params), do: {:ok, from_map(file)}
  def dump(data, _dumper, _params) when is_map(data), do: {:ok, from_map(data)}
  def dump(_data, _dumper, _params), do: :error

  @impl true
  def equal?(%{key: key1}, %{key: key2}, _params), do: key1 == key2
  def equal?(val1, val2, _params), do: val1 == val2

  defp from_map(map) do
    Map.take(map, [
      :key,
      :status,
      :filename,
      :content_type,
      :metadata
    ])
  end
end