defmodule Image.Components.Signing.Cloudinary do
@moduledoc """
Cloudinary-flavoured client-side URL signing.
Wire-format-compatible with `Image.Plug.Provider.Cloudinary.Signing`
on the server side and with Cloudinary's hosted signed URLs.
Sign-only — verification happens at the back-end.
SHA-256 over `<transforms>/<source><api-secret>`. Signature is
inserted as a path segment `s--<base64url-truncated-32>--`
between the delivery type (`upload`) and the first transform
stage.
"""
@digest_length 32
@doc """
Signs a Cloudinary path with the first key in `keys`.
### Arguments
* `path` is the path of a Cloudinary URL **without** an
`s--<sig>--` segment.
* `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; key rotation handles long-term revocation.
### Returns
* The path with `s--<sig>--/` inserted between the delivery type
and the transforms.
### Examples
iex> Image.Components.Signing.Cloudinary.sign(
...> "/demo/image/upload/w_200/cat.jpg",
...> ["api_secret"]
...> )
...> |> String.contains?("/s--")
true
"""
@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
defp split_at_delivery(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 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