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