Skip to main content

lib/image/components/signing/imagekit.ex

defmodule Image.Components.Signing.ImageKit do
  @moduledoc """
  ImageKit-flavoured client-side URL signing.

  Wire-format-compatible with `Image.Plug.Provider.ImageKit.Signing`
  on the server side and with ImageKit's hosted signed URLs.
  Sign-only — verification happens at the back-end.

  HMAC-SHA1 over the path-and-query (`ik-s` excluded). Signature
  appended as `?ik-s=<hex>` (or `&ik-s=<hex>` if a query is already
  present).
  """

  @signature_param "ik-s"
  @expiry_param "ik-t"

  @doc """
  Signs `path_with_query` with the first key in `keys`.

  ### Arguments

  * `path_with_query` is the ImageKit request path, optionally
    with an existing query string.

  * `keys` is a non-empty list of ImageKit private API keys.

  ### Options

  * `:expires_at` — `DateTime` or unix-seconds. Adds an
    `ik-t=<unix>` parameter; the back-end's verifier rejects
    after that time.

  ### Returns

  * The path with `?ik-s=<hex>` (and optional `?ik-t=…`) appended.

  ### Examples

      iex> Image.Components.Signing.ImageKit.sign("/demo/tr:w-200/cat.jpg", ["k"])
      ...> |> String.contains?("?ik-s=")
      true

  """
  @spec sign(String.t(), [String.t(), ...], keyword()) :: String.t()
  def sign(path_with_query, [primary_key | _] = _keys, options \\ [])
      when is_binary(path_with_query) and is_binary(primary_key) do
    base =
      case encode_expiry(Keyword.get(options, :expires_at)) do
        nil -> path_with_query
        param -> append_query(path_with_query, param)
      end

    signature = hmac(primary_key, base)
    append_query(base, "#{@signature_param}=#{signature}")
  end

  defp encode_expiry(nil), do: nil
  defp encode_expiry(value) when is_integer(value), do: "#{@expiry_param}=#{value}"

  defp encode_expiry(%DateTime{} = dt) do
    "#{@expiry_param}=#{DateTime.to_unix(dt)}"
  end

  defp append_query(path, param) do
    case String.contains?(path, "?") do
      true -> "#{path}&#{param}"
      false -> "#{path}?#{param}"
    end
  end

  defp hmac(key, payload) when is_binary(key) and is_binary(payload) do
    :crypto.mac(:hmac, :sha, key, payload) |> Base.encode16(case: :lower)
  end
end