Skip to main content

lib/image/components/signing.ex

defmodule Image.Components.Signing do
  @moduledoc """
  Client-side HMAC signing for URLs emitted by `Image.Components.URL`.

  Functionally equivalent to `Image.Plug.Signing` on the back-end:
  HMAC-SHA256 over the path-and-query (with the `sig` parameter
  removed), hex-encoded, appended as `?sig=<hex>`. Sign-only — the
  back-end verifies. Use this when your `Image.Plug` deployment is
  configured with `:signing` and you need the component to emit
  authentic URLs.

  Self-contained — does not depend on `image_plug`. Both packages
  share the wire format, not the code.

  ### Examples

      iex> path = "/cdn-cgi/image/width=200/photo.jpg"
      iex> signed = Image.Components.Signing.sign(path, ["secret"])
      iex> String.starts_with?(signed, path <> "?sig=")
      true

  """

  @signature_param "sig"
  @expiry_param "exp"

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

  ### Arguments

  * `path` is the request path (with or without an existing query
    string).

  * `keys` is a non-empty list of secret-key strings. The first
    key is used.

  ### Options

  * `:expires_at` — `DateTime` or unix-seconds integer. When set,
    appends `?exp=<unix-seconds>` and signs the result.

  ### Returns

  * The path with `?sig=<hex>` (and optional `?exp=…`) appended.

  ### Examples

      iex> Image.Components.Signing.sign("/foo.jpg", ["secret"])
      ...> |> String.starts_with?("/foo.jpg?sig=")
      true

      iex> Image.Components.Signing.sign("/foo.jpg?other=1", ["secret"])
      ...> =~ "?other=1&sig="
      true

  """
  @spec sign(String.t(), [String.t(), ...], keyword()) :: String.t()
  def sign(path, [primary_key | _], options \\ [])
      when is_binary(path) and is_binary(primary_key) do
    expiry_param = encode_expiry(Keyword.get(options, :expires_at))

    base_with_expiry =
      case expiry_param do
        nil -> path
        param -> append_query(path, param)
      end

    signature = hmac(primary_key, base_with_expiry)
    append_query(base_with_expiry, "#{@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, :sha256, key, payload) |> Base.encode16(case: :lower)
  end
end