lib/imgproxy.ex

defmodule Imgproxy do
  @moduledoc """
  `Imgproxy` generates urls for use with an [imgproxy](https://imgproxy.net) server.
  """

  defstruct source_url: nil, options: [], extension: nil, prefix: nil, key: nil, salt: nil

  alias __MODULE__

  @type t :: %__MODULE__{
          source_url: nil | String.t(),
          options: keyword(list()),
          extension: nil | String.t(),
          prefix: nil | String.t(),
          key: nil | String.t(),
          salt: nil | String.t()
        }

  @typedoc """
  A number of pixels to be used as a dimension.
  """
  @type dimension :: float() | integer() | String.t()

  @typedoc """
  Provide type and enlarge configuration arguments to a resize option.
  """
  @type resize_opts :: [
          type: String.t(),
          enlarge: boolean()
        ]

  @doc """
  Generate a new `t:Imgproxy.t/0` struct for the given image source URL.
  """
  @spec new(String.t()) :: t()
  def new(source_url) when is_binary(source_url) do
    %Imgproxy{
      source_url: source_url,
      prefix: Application.get_env(:imgproxy, :prefix),
      key: Application.get_env(:imgproxy, :key),
      salt: Application.get_env(:imgproxy, :salt)
    }
  end

  @doc """
  Add a [formatting option](https://docs.imgproxy.net/generating_the_url_advanced) to the `t:Imgproxy.t/0`.

  For instance, to add the [padding](https://docs.imgproxy.net/generating_the_url_advanced?id=padding) option
  with a 10px padding on all sides, you can use:

      iex> img = Imgproxy.new("http://example.com/image.jpg")
      iex> Imgproxy.add_option(img, :padding, [10, 10, 10, 10]) |> to_string()
      "https://imgcdn.example.com/insecure/padding:10:10:10:10/aHR0cDovL2V4YW1wbGUuY29tL2ltYWdlLmpwZw"
  """
  @spec add_option(t(), atom(), list()) :: t()
  def add_option(%Imgproxy{options: opts} = img, name, args)
      when is_atom(name) and is_list(args) do
    %Imgproxy{img | options: Keyword.put(opts, name, args)}
  end

  @doc """
  Set the [gravity](https://docs.imgproxy.net/generating_the_url_advanced?id=gravity) option.
  """
  @spec set_gravity(t(), atom(), dimension(), dimension()) :: t()
  def set_gravity(img, type, xoffset \\ 0, yoffset \\ 0)

  def set_gravity(img, "sm", _xoffset, _yoffset) do
    add_option(img, :g, [:sm])
  end

  def set_gravity(img, :sm, _xoffset, _yoffset) do
    add_option(img, :g, [:sm])
  end

  def set_gravity(img, type, xoffset, yoffset) do
    add_option(img, :g, [type, xoffset, yoffset])
  end

  @doc """
  [Resize](https://docs.imgproxy.net/generating_the_url_advanced?id=resize) an image to the given width and height.

  Options include:
    * type: "fit" (default), "fill", or "auto"
    * enlarge: enlarge if necessary (`false` by default)
  """
  @spec resize(t(), dimension(), dimension(), resize_opts()) :: t()
  def resize(img, width, height, opts \\ []) do
    type = Keyword.get(opts, :type, "fit")
    enlarge = Keyword.get(opts, :enlarge, false)
    add_option(img, :rs, [type, width, height, enlarge])
  end

  @doc """
  [Crop](https://docs.imgproxy.net/generating_the_url_advanced?id=crop) an image to the given width and height.

  Accepts an optional [gravity](https://docs.imgproxy.net/generating_the_url_advanced?id=gravity) parameter, by
  default it is "ce:0:0" for center gravity with no offset.
  """
  @spec crop(t(), dimension(), dimension(), String.t()) :: t()
  def crop(img, width, height, gravity \\ "ce:0:0") do
    add_option(img, :c, [width, height, gravity])
  end

  @doc """
  Set the file extension (which will produce an image of that type).

  For instance, setting the extension to "png" will result in a PNG being created:

      iex> img = Imgproxy.new("http://example.com/image.jpg")
      iex> Imgproxy.set_extension(img, "png") |> to_string()
      "https://imgcdn.example.com/insecure/aHR0cDovL2V4YW1wbGUuY29tL2ltYWdlLmpwZw.png"
  """
  @spec set_extension(t(), String.t()) :: t()
  def set_extension(img, "." <> extension), do: set_extension(img, extension)

  def set_extension(img, extension), do: %Imgproxy{img | extension: extension}

  @doc """
  Generate an imgproxy URL.

  ## Example

      iex> Imgproxy.to_string(Imgproxy.new("https://placekitten.com/200/300"))
      "https://imgcdn.example.com/insecure/aHR0cHM6Ly9wbGFjZWtpdHRlbi5jb20vMjAwLzMwMA"
  """
  @spec to_string(t()) :: String.t()
  defdelegate to_string(img), to: String.Chars.Imgproxy
end

defimpl String.Chars, for: Imgproxy do
  def to_string(%Imgproxy{prefix: prefix, key: key, salt: salt} = img) do
    path = build_path(img)
    signature = gen_signature(path, key, salt)
    Path.join([prefix || "", signature, path])
  end

  #  @spec build_path(img_url :: String.t(), opts :: image_opts) :: String.t()
  defp build_path(%Imgproxy{source_url: source_url, options: opts, extension: ext}) do
    ["/" | Enum.map(opts, &option_to_string/1)]
    |> Path.join()
    |> Path.join(encode_source_url(source_url, ext))
  end

  defp encode_source_url(source_url, nil) do
    Base.url_encode64(source_url, padding: false)
  end

  defp encode_source_url(source_url, extension) do
    encode_source_url(source_url, nil) <> "." <> extension
  end

  defp option_to_string({name, args}) when is_list(args) do
    [name | args]
    |> Enum.map(&Kernel.to_string/1)
    |> Enum.join(":")
  end

  defp gen_signature(path, key, salt) when is_binary(key) and is_binary(salt) do
    decoded_key = Base.decode16!(key, case: :lower)
    decoded_salt = Base.decode16!(salt, case: :lower)

    :hmac
    |> :crypto.mac(:sha256, decoded_key, decoded_salt <> path)
    |> Base.url_encode64(padding: false)
  end

  defp gen_signature(_path, _key, _salt), do: "insecure"
end