defmodule ImgproxyUrl do @moduledoc """ Signed imgproxy URL generator with HMAC-SHA256. By [Shiko](https://shiko.vet) — tech@shiko.vet ## Setup {:imgproxy_url, "~> 0.1"} config :imgproxy_url, host: "https://img.example.com", key: "your_hex_key", salt: "your_hex_salt" ## Usage ImgproxyUrl.build("s3://bucket/photo.jpg", width: 200, height: 200) # => "https://img.example.com//rs:fit:200:200/q:80/plain/s3://bucket/photo.jpg@webp" Zero dependencies beyond Erlang stdlib `:crypto`. """ @doc """ Builds a signed imgproxy URL. ## Options * `:width` - Target width (required) * `:height` - Target height (required) * `:quality` - JPEG/WebP quality 1-100 (default: 80) * `:resize` - Resize type: "fit", "fill", "auto" (default: "fit") * `:format` - Output format (default: "webp") * `:host` - Override configured host * `:key` - Override configured key * `:salt` - Override configured salt """ @spec build(String.t(), keyword()) :: String.t() def build(source_url, opts \\ []) do width = Keyword.fetch!(opts, :width) height = Keyword.fetch!(opts, :height) quality = Keyword.get(opts, :quality, 80) resize = Keyword.get(opts, :resize, "fit") format = Keyword.get(opts, :format, "webp") encoded_source = Base.url_encode64(source_url, padding: false) processing_path = "/rs:#{resize}:#{width}:#{height}/q:#{quality}/#{encoded_source}.#{format}" signature = sign(processing_path, opts) "#{host(opts)}/#{signature}#{processing_path}" end @doc """ Builds a signed imgproxy URL using the `plain` source encoding. Useful when the source URL should be human-readable. """ @spec build_plain(String.t(), keyword()) :: String.t() def build_plain(source_url, opts \\ []) do width = Keyword.fetch!(opts, :width) height = Keyword.fetch!(opts, :height) quality = Keyword.get(opts, :quality, 80) resize = Keyword.get(opts, :resize, "fit") format = Keyword.get(opts, :format, "webp") processing_path = "/rs:#{resize}:#{width}:#{height}/q:#{quality}/plain/#{source_url}@#{format}" signature = sign(processing_path, opts) "#{host(opts)}/#{signature}#{processing_path}" end @doc """ Signs an imgproxy processing path with HMAC-SHA256. """ @spec sign(String.t(), keyword()) :: String.t() def sign(path, opts \\ []) do key = decode_hex(key(opts)) salt = decode_hex(salt(opts)) :crypto.mac(:hmac, :sha256, key, salt <> path) |> Base.url_encode64(padding: false) end defp decode_hex(hex), do: Base.decode16!(hex, case: :mixed) defp host(opts), do: opts[:host] || Application.get_env(:imgproxy_url, :host, "http://localhost:8080") defp key(opts), do: opts[:key] || Application.get_env(:imgproxy_url, :key, "") defp salt(opts), do: opts[:salt] || Application.get_env(:imgproxy_url, :salt, "") end