Skip to main content

lib/image/plug/provider/cloudinary/signing.ex

defmodule Image.Plug.Provider.Cloudinary.Signing do
  @moduledoc """
  Cloudinary-flavoured URL signing.

  Per [Cloudinary's docs](https://cloudinary.com/documentation/control_access_to_media#signed_delivery_urls):

  * SHA-256 (modern, default) over the canonical-string `<transforms>/<public-id><api-secret>`.
  * Signature appears as a path segment `s--<base64url-truncated>--`
    inserted between the delivery type (`upload`) and the first
    transform stage.
  * Cloudinary truncates the SHA-256 digest to 32 url-safe-base64
    characters by default. We follow that convention; longer
    signatures are rejected as invalid even if the prefix matches.

  v0.1 ships SHA-256 only. Cloudinary's older SHA-1 scheme (8-char
  signature, no `--` delimiters around the segment) is not
  supported — open an issue if you need it.

  Sign and verify share the same wire format. The module mirrors
  `Image.Plug.Signing`'s public API so it's a drop-in alternative
  when configuring the Cloudinary provider.
  """

  alias Image.Plug.Error

  @signature_re ~r/^s--([A-Za-z0-9_-]+)--$/
  @digest_length 32

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

  ### Arguments

  * `path` is the path-and-query of a Cloudinary URL **without** an
    `s--<sig>--` segment. The signer inserts the segment between
    `<delivery>` (e.g. `upload`) and the first transform stage.

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

  ### Options

  * `:expires_at` — currently unused. Cloudinary's signed-URL flow
    relies on path-bound signatures rather than a per-URL expiry
    parameter; expirations live on the API-secret rotation cycle.

  ### Returns

  The signed path.
  """
  @spec sign(String.t(), [String.t(), ...], keyword()) :: String.t()
  def sign(path, [primary_key | _] = _keys, _options \\ [])
      when is_binary(path) and is_binary(primary_key) do
    {prefix, transforms_and_source} = split_at_delivery(path)
    signature = compute_signature(transforms_and_source, primary_key)
    "#{prefix}/s--#{signature}--/#{transforms_and_source}"
  end

  @doc """
  Verifies the signature on a Cloudinary path.

  Returns `:ok` or an `Image.Plug.Error` with one of
  `:signature_required`, `:invalid_signature`.

  ### Options

  * `:required?` — when `true`, missing `s--<sig>--` segment produces
    `:signature_required`. Default `false`.
  """
  @spec verify(String.t(), [String.t(), ...], keyword()) :: :ok | {:error, Error.t()}
  def verify(path, keys, options \\ [])
      when is_binary(path) and is_list(keys) do
    required? = Keyword.get(options, :required?, false)

    case extract_signature(path) do
      {nil, _} when required? ->
        {:error,
         Error.new(
           :signature_required,
           "request must carry a Cloudinary `s--<sig>--` segment"
         )}

      {nil, _} ->
        :ok

      {provided, transforms_and_source} ->
        check_signature(transforms_and_source, provided, keys)
    end
  end

  @doc """
  Splits a Cloudinary path into `{prefix, transforms_and_source}`
  where `prefix` is `/<account>/<resource-type>/<delivery>` and the
  remainder is the rest of the path with no leading `/`.

  Used by both signing and verification to canonicalise.
  """
  @spec split_at_delivery(String.t()) :: {String.t(), String.t()}
  def split_at_delivery(path) when is_binary(path) do
    segments = String.split(String.trim_leading(path, "/"), "/")

    case segments do
      [account, resource_type, delivery | rest] ->
        prefix = "/" <> account <> "/" <> resource_type <> "/" <> delivery
        {prefix, Enum.join(rest, "/")}

      _ ->
        {"", String.trim_leading(path, "/")}
    end
  end

  defp extract_signature(path) do
    {prefix, after_prefix} = split_at_delivery(path)

    case String.split(after_prefix, "/", parts: 2) do
      [first, rest] ->
        case Regex.run(@signature_re, first) do
          [_, sig] -> {sig, rest}
          _ -> {nil, prefix <> "/" <> after_prefix}
        end

      _ ->
        {nil, prefix <> "/" <> after_prefix}
    end
  end

  defp check_signature(transforms_and_source, provided, keys) do
    if Enum.any?(keys, fn key ->
         expected = compute_signature(transforms_and_source, key)
         Plug.Crypto.secure_compare(provided, expected)
       end) do
      :ok
    else
      {:error, Error.new(:invalid_signature, "request signature does not match")}
    end
  end

  # The canonical string is `<transforms>/<source><api-secret>`. The
  # SHA-256 digest is truncated to 32 url-safe-base64 characters
  # (Cloudinary's default; matches `signature_algorithm: 'sha256'`).
  defp compute_signature(transforms_and_source, key) do
    :crypto.hash(:sha256, transforms_and_source <> key)
    |> Base.url_encode64(padding: false)
    |> String.slice(0, @digest_length)
  end
end