Skip to main content

lib/image/plug/provider/imgix/url.ex

defmodule Image.Plug.Provider.Imgix.URL do
  @moduledoc """
  URL-shape recognition for the [imgix URL grammar](https://docs.imgix.com/en/latest/setup/serving-images).

  Two source modes per imgix's documentation:

  * **Web folder source**: the path *is* the source key
    (`/photos/sunset.jpg`). The host's `Image.Plug.SourceResolver`
    (typically `File` or `Hosted`) maps the path to bytes.

  * **Web proxy source**: the path is a percent-encoded absolute
    URL (`/https%3A%2F%2Fassets.example.com%2Fsunset.jpg`).
    Treated as a `:url` source; resolved by
    `Image.Plug.SourceResolver.HTTP`.

  Unlike Cloudflare, imgix has no path marker — every request
  under the configured mount is presumed to be a transform request.
  Options come from the query string, not the path.
  """

  alias Image.Plug.{Error, Source}

  @typedoc """
  The recognised URL shape.
  """
  @type recognised :: %{
          shape: :imgix,
          options: String.t(),
          source: Source.t()
        }

  @doc """
  Parses the request path of a `Plug.Conn` into a recognised URL
  shape.

  ### Arguments

  * `conn` is a `Plug.Conn` struct.

  * `options` is a keyword list. The following keys are honoured:

  ### Options

  * `:mount` — string path prefix the plug is mounted under.
    Stripped before treating the rest as the source path. Defaults
    to `""`.

  ### Returns

  * `{:ok, recognised}` on a successful match.

  * `{:error, %Image.Plug.Error{tag: :malformed_url}}` when the
    path does not sit under the configured mount.

  * `{:error, %Image.Plug.Error{tag: :invalid_option}}` when the
    decoded source is malformed (e.g. relative path, unparseable
    URL).

  ### Examples

      iex> conn = %Plug.Conn{
      ...>   path_info: ["photos", "sunset.jpg"],
      ...>   request_path: "/photos/sunset.jpg",
      ...>   query_string: "w=200&fit=crop"
      ...> }
      iex> {:ok, %{shape: :imgix, options: "w=200&fit=crop", source: source}} =
      ...>   Image.Plug.Provider.Imgix.URL.parse(conn, [])
      iex> source.kind
      :path
      iex> source.ref
      "/photos/sunset.jpg"

  """
  @spec parse(Plug.Conn.t(), keyword()) :: {:ok, recognised()} | {:error, Error.t()}
  def parse(%Plug.Conn{path_info: path_info, query_string: query_string}, options)
      when is_list(options) do
    mount_segments = mount_segments(Keyword.get(options, :mount, ""))
    decoded = Enum.map(path_info, &URI.decode/1)

    case strip_prefix(decoded, mount_segments) do
      {:ok, []} ->
        {:error, Error.new(:malformed_url, "imgix request has no source path")}

      {:ok, segments} ->
        with {:ok, source} <- build_source(segments) do
          {:ok, %{shape: :imgix, options: query_string, source: source}}
        end

      :error ->
        {:error,
         Error.new(:malformed_url, "request path does not sit under the configured mount")}
    end
  end

  defp mount_segments(""), do: []

  defp mount_segments(mount) when is_binary(mount) do
    mount
    |> String.trim_leading("/")
    |> String.trim_trailing("/")
    |> String.split("/", trim: true)
  end

  defp strip_prefix(path_info, []), do: {:ok, path_info}

  defp strip_prefix(path_info, mount_segments) do
    if List.starts_with?(path_info, mount_segments) do
      {:ok, Enum.drop(path_info, length(mount_segments))}
    else
      :error
    end
  end

  defp build_source([first | _rest] = segments) do
    cond do
      # A single segment that decodes to an http(s) URL is a web
      # proxy source. Imgix's convention is to percent-encode the
      # entire URL into one path segment.
      length(segments) == 1 and
          (String.starts_with?(first, "http://") or String.starts_with?(first, "https://")) ->
        Source.url(first)

      true ->
        Source.path("/" <> Enum.join(segments, "/"))
    end
  end
end