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