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=`. 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=` and signs the result. ### Returns * The path with `?sig=` (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