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. * `:unrecognised` when the path is not under the configured mount. The caller should pass the request through untouched. * `{:error, %Image.Plug.Error{tag: :malformed_url}}` when the path is under the mount but names no source. * `{: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()} | :unrecognised | {: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 -> # Not under the configured mount, so this request is not # addressed to the plug. Pass it through untouched. :unrecognised 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 # 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. if length(segments) == 1 and (String.starts_with?(first, "http://") or String.starts_with?(first, "https://")) do Source.url(first) else Source.path("/" <> Enum.join(segments, "/")) end end end