Skip to main content

lib/image/plug/options.ex

defmodule Image.Plug.Options do
  @moduledoc """
  Validated, frozen configuration for `Image.Plug`.

  Built once at compile/boot time by `Image.Plug.init/1`. The
  request path then only reads struct fields.

  Construct with `new!/1`. Direct struct construction is discouraged —
  the validator enforces invariants such as "the provider module must
  export `parse/2`" that are not visible from the struct shape alone.
  """

  @type module_with_options :: {module(), keyword()}

  @typedoc """
  Signing configuration. `nil` disables signing entirely.

  When set, every request URL is verified against the configured
  `:keys` list (HMAC-SHA256). When `:required?` is `true`, an
  unsigned URL produces `:signature_required`. When `:required?` is
  `false`, unsigned URLs pass through but a signed URL with an
  invalid signature still 401s — defense in depth.
  """
  @type signing_options ::
          nil
          | %{
              required(:keys) => [String.t(), ...],
              optional(:required?) => boolean()
            }

  @type t :: %__MODULE__{
          provider: module(),
          provider_options: keyword(),
          source_resolver: module(),
          source_resolver_options: keyword(),
          variant_store: module(),
          variant_store_options: keyword(),
          on_error: Image.Plug.Pipeline.on_error(),
          max_pixels: pos_integer(),
          request_timeout: pos_integer(),
          telemetry_prefix: [atom(), ...],
          signing: signing_options()
        }

  @enforce_keys [:provider, :source_resolver]
  defstruct provider: nil,
            provider_options: [],
            source_resolver: nil,
            source_resolver_options: [],
            variant_store: Image.Plug.VariantStore.ETS,
            variant_store_options: [],
            on_error: :auto,
            max_pixels: 25_000_000,
            request_timeout: 10_000,
            telemetry_prefix: [:image_plug],
            signing: nil

  @doc """
  Validates a keyword list and returns an `Image.Plug.Options`
  struct.

  ### Arguments

  * `options` is a keyword list. The `:provider` and `:source_resolver`
    keys are required. Each must be a `{module, options}` tuple.

  ### Options

  * `:provider` (required) — `{module, options}` for an
    `Image.Plug.Provider` implementation.

  * `:source_resolver` (required) — `{module, options}` for an
    `Image.Plug.SourceResolver` implementation.

  * `:variant_store` — `{module, options}` for an
    `Image.Plug.VariantStore` implementation. Defaults to
    `{Image.Plug.VariantStore.ETS, []}`.

  * `:on_error` — error policy atom or `{:status, code}` tuple.
    Defaults to `:auto`.

  * `:max_pixels` — soft upper bound on the output pixel count.
    Defaults to `25_000_000`.

  * `:request_timeout` — total per-request budget in milliseconds.
    Defaults to `10_000`.

  * `:telemetry_prefix` — list of atoms prepended to telemetry event
    names. Defaults to `[:image_plug]`.

  ### Returns

  * An `Image.Plug.Options` struct.

  ### Examples

      iex> defmodule TestProv do
      ...>   @behaviour Image.Plug.Provider
      ...>   @impl true
      ...>   def parse(_conn, _options), do: {:error, Image.Plug.Error.new(:not_implemented, "test")}
      ...> end
      iex> defmodule TestRes do
      ...>   @behaviour Image.Plug.SourceResolver
      ...>   @impl true
      ...>   def load(_source, _options), do: {:error, Image.Plug.Error.new(:not_implemented, "test")}
      ...> end
      iex> options = Image.Plug.Options.new!(
      ...>   provider: {TestProv, []},
      ...>   source_resolver: {TestRes, []}
      ...> )
      iex> options.provider
      TestProv
      iex> options.on_error
      :auto

  """
  @spec new!(keyword()) :: t()
  def new!(options) when is_list(options) do
    {provider_mod, provider_opts} = take_module!(options, :provider)
    {resolver_mod, resolver_opts} = take_module!(options, :source_resolver)

    {store_mod, store_opts} =
      Keyword.get(options, :variant_store, {Image.Plug.VariantStore.ETS, []})
      |> normalise_module!(:variant_store)

    on_error = Keyword.get(options, :on_error, :auto)
    validate_on_error!(on_error)

    %__MODULE__{
      provider: provider_mod,
      provider_options: provider_opts,
      source_resolver: resolver_mod,
      source_resolver_options: resolver_opts,
      variant_store: store_mod,
      variant_store_options: store_opts,
      on_error: on_error,
      max_pixels: Keyword.get(options, :max_pixels, 25_000_000),
      request_timeout: Keyword.get(options, :request_timeout, 10_000),
      telemetry_prefix: Keyword.get(options, :telemetry_prefix, [:image_plug]),
      signing: validate_signing!(Keyword.get(options, :signing))
    }
  end

  defp validate_signing!(nil), do: nil

  defp validate_signing!(%{keys: [_ | _] = keys} = signing) when is_list(keys) do
    if Enum.all?(keys, &is_binary/1) do
      Map.put_new(signing, :required?, false)
    else
      raise ArgumentError,
            "Image.Plug: :signing :keys must all be binaries, got: #{inspect(keys)}"
    end
  end

  defp validate_signing!(other) do
    raise ArgumentError,
          "Image.Plug: :signing must be nil or %{keys: [\"...\"], required?: bool}, got: #{inspect(other)}"
  end

  defp take_module!(options, key) do
    case Keyword.fetch(options, key) do
      {:ok, value} -> normalise_module!(value, key)
      :error -> raise ArgumentError, "Image.Plug: required option #{inspect(key)} is missing"
    end
  end

  defp normalise_module!({module, opts}, _key) when is_atom(module) and is_list(opts) do
    {module, opts}
  end

  defp normalise_module!(module, _key) when is_atom(module) do
    {module, []}
  end

  defp normalise_module!(other, key) do
    raise ArgumentError,
          "Image.Plug: option #{inspect(key)} must be a module or " <>
            "a {module, keyword} tuple, got: #{inspect(other)}"
  end

  defp validate_on_error!(value) do
    case value do
      v when v in [:auto, :render_error_image, :fallback_to_source, :status_text, :raise] ->
        :ok

      {:status, code} when is_integer(code) and code in 100..599 ->
        :ok

      other ->
        raise ArgumentError,
              "Image.Plug: :on_error must be :auto, :render_error_image, " <>
                ":fallback_to_source, :status_text, :raise, or {:status, code}, got: #{inspect(other)}"
    end
  end
end