Skip to main content

lib/phoenix_image/plug.ex

defmodule PhoenixImage.Plug do
  @moduledoc """
  HTTP contract for on-demand image optimization.

  ## Plug Options

  - `:cache_control` - response cache header
    (default: `"public, max-age=31536000, immutable"`).
  - `:allowed_hosts` - list of additional hosts allowed for absolute `src`
    values. Same-host requests are always allowed.

  ## Query Params

  - `src` (required): absolute `http/https` URL or root-relative path.
  - `w` (optional): positive integer width, max `8192`.
  - `h` (optional): positive integer height, max `8192`.
  - `q` (optional): quality in `1..100`.
  - `f` (optional): `webp|avif|jpg|png`, defaults to `webp`.
  - `upscale` (optional): `true|false`, defaults to `false`.

  ## Responses

  - `200` with optimized binary body.
    May include `x-phoenix-image-upscale: skipped` when enlargement was clamped.
  - `400` invalid parameters.
  - `403` disallowed source host.
  - `404` source not found upstream.
  - `500` upstream or processing error.
  """
  import Plug.Conn

  @behaviour Plug
  @default_cache_control "public, max-age=31536000, immutable"
  @max_upscale_factor 2.0
  @max_dimension 8192

  @impl true
  def init(opts) do
    opts
    |> Keyword.put_new(:cache_control, @default_cache_control)
    |> Keyword.put_new(:allowed_hosts, [])
  end

  @impl true
  def call(conn, opts) do
    conn = fetch_query_params(conn)
    cache_control = opts[:cache_control] || @default_cache_control

    with {:ok, source, process_opts} <- validate_params(conn, conn.query_params, opts),
         {:ok, binary, mime_type, meta} <- PhoenixImage.Optimizer.process(source, process_opts) do
      conn
      |> maybe_put_upscale_header(meta)
      |> put_resp_content_type(mime_type)
      |> put_resp_header("cache-control", cache_control)
      |> send_resp(200, binary)
    else
      {:error, {:bad_request, reason}} ->
        send_resp(conn, 400, "Invalid parameters: #{reason}")

      {:error, {:forbidden, reason}} ->
        send_resp(conn, 403, "Forbidden: #{reason}")

      {:error, reason} when is_binary(reason) ->
        status = if String.contains?(reason, "status 404"), do: 404, else: 500
        send_resp(conn, status, "Error: #{reason}")
    end
  end

  defp validate_params(conn, %{"src" => source} = params, opts) do
    with {:ok, width} <- parse_dimension("w", params["w"]),
         {:ok, height} <- parse_dimension("h", params["h"]),
         {:ok, quality} <- parse_quality(params["q"]),
         {:ok, format} <- parse_format(params["f"]),
         {:ok, upscale} <- parse_upscale(params["upscale"]),
         {:ok, source_uri} <- normalize_source(source, conn, opts) do
      options =
        []
        |> maybe_put(:width, width)
        |> maybe_put(:height, height)
        |> maybe_put(:quality, quality)
        |> maybe_put(:format, format)
        |> maybe_put(:upscale, upscale)
        |> maybe_put(:max_upscale_factor, @max_upscale_factor)

      {:ok, source_uri, options}
    end
  end

  defp validate_params(_conn, _params, _opts),
    do: {:error, {:bad_request, "Missing 'src' parameter"}}

  defp parse_dimension(_param, nil), do: {:ok, nil}

  defp parse_dimension(param, value) do
    with {:ok, int} <- parse_positive_int(value),
         :ok <- validate_max_dimension(param, int) do
      {:ok, int}
    end
  end

  defp validate_max_dimension(_param, int) when int <= @max_dimension, do: :ok

  defp validate_max_dimension(param, _int),
    do: {:error, {:bad_request, "#{param} must be <= #{@max_dimension}"}}

  defp parse_quality(nil), do: {:ok, nil}

  defp parse_quality(value) do
    with {:ok, int} <- parse_positive_int(value),
         :ok <- validate_quality(int) do
      {:ok, int}
    end
  end

  defp validate_quality(int) when int in 1..100, do: :ok
  defp validate_quality(_int), do: {:error, {:bad_request, "q must be in range 1..100"}}

  defp parse_format(nil), do: {:ok, nil}

  defp parse_format("webp"), do: {:ok, :webp}
  defp parse_format("avif"), do: {:ok, :avif}
  defp parse_format("jpg"), do: {:ok, :jpg}
  defp parse_format("png"), do: {:ok, :png}

  defp parse_format(_format), do: {:error, {:bad_request, "f must be one of webp,avif,jpg,png"}}

  defp parse_upscale(nil), do: {:ok, false}
  defp parse_upscale("true"), do: {:ok, true}
  defp parse_upscale("false"), do: {:ok, false}
  defp parse_upscale(_), do: {:error, {:bad_request, "upscale must be true or false"}}

  defp parse_positive_int(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, ""} when int > 0 -> {:ok, int}
      _ -> {:error, {:bad_request, "must be a positive integer"}}
    end
  end

  defp parse_positive_int(_value), do: {:error, {:bad_request, "must be a positive integer"}}

  defp normalize_source(source, conn, opts) when is_binary(source) do
    uri = URI.parse(source)

    cond do
      uri.scheme in ["http", "https"] ->
        validate_allowed_host(uri, conn, opts)

      String.starts_with?(source, "/") ->
        base = base_url(conn)
        resolved = URI.merge(base, source)
        validate_allowed_host(resolved, conn, opts)

      true ->
        {:error, {:bad_request, "src must be an absolute http(s) URL or root-relative path"}}
    end
  end

  defp normalize_source(_source, _conn, _opts),
    do: {:error, {:bad_request, "src must be an absolute http(s) URL or root-relative path"}}

  defp validate_allowed_host(%URI{host: nil}, _conn, _opts),
    do: {:error, {:bad_request, "src must include a host"}}

  defp validate_allowed_host(%URI{host: host} = uri, conn, opts) do
    allowed_hosts =
      opts
      |> Keyword.get(:allowed_hosts, [])
      |> Enum.map(&to_string/1)

    same_host? = host == conn.host
    listed_host? = host in allowed_hosts

    if same_host? or listed_host? do
      {:ok, URI.to_string(uri)}
    else
      {:error, {:forbidden, "src host not allowed"}}
    end
  end

  defp maybe_put(opts, _key, nil), do: opts
  defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)

  defp maybe_put_upscale_header(conn, %{upscale_skipped: true}),
    do: put_resp_header(conn, "x-phoenix-image-upscale", "skipped")

  defp maybe_put_upscale_header(conn, _meta), do: conn

  defp base_url(conn) do
    scheme = Atom.to_string(conn.scheme)
    default_port = if conn.scheme == :https, do: 443, else: 80
    port_part = if conn.port != default_port, do: ":#{conn.port}", else: ""
    "#{scheme}://#{conn.host}#{port_part}"
  end
end