defmodule Image.Plug do
@moduledoc """
The library's request entry point.
Mount this plug under whatever path your host application uses (e.g.
`forward "/img", to: Image.Plug, init_opts: [...]` for
`Plug.Router`, or `plug Image.Plug, [...]` from a Phoenix
endpoint).
### Request lifecycle
Each request flows through:
1. `provider.parse/2` — produces either a fully-formed pipeline +
source, a variant lookup + source, or a passthrough source.
2. Variant resolution against the configured
`Image.Plug.VariantStore`. Variant URLs of the form
`/<account>/<image-id>/<variant-name>` are expanded to the
stored pipeline; ad-hoc URLs skip this step.
3. `Image.Plug.Pipeline.Normaliser.normalise/1`.
4. `source_resolver.load/2` — opens the source as a
`Vix.Vips.Image`, preferring streaming decode.
5. `Image.Plug.Pipeline.Interpreter.execute/2`.
6. `Image.Plug.Pipeline.Encoder.encode/3` — produces a streaming
body when possible.
7. The plug pipes the body to the client via
`Plug.Conn.send_chunked/2` + `Plug.Conn.chunk/2`. Buffered bytes
bodies use `Plug.Conn.send_resp/3`.
"""
@behaviour Plug
alias Image.Plug.{Error, Pipeline}
alias Image.Plug.Options
require Logger
@impl Plug
@doc """
Validates configuration and returns an opaque options struct passed
through to every `call/2` invocation.
Raises `ArgumentError` if required configuration is missing or
malformed. Configuration errors are programmer errors and must surface
at boot time, not per-request.
"""
@spec init(keyword()) :: Options.t()
def init(options) when is_list(options) do
Options.new!(options)
end
@impl Plug
@doc """
Handles a single request end-to-end.
"""
@spec call(Plug.Conn.t(), Options.t()) :: Plug.Conn.t()
def call(%Plug.Conn{} = conn, %Options{} = options) do
start_time = System.monotonic_time()
metadata = %{request_path: conn.request_path, provider: options.provider}
:telemetry.execute(
options.telemetry_prefix ++ [:request, :start],
%{system_time: System.system_time()},
metadata
)
try do
result =
case run(conn, options) do
{:ok, conn} ->
conn
{:error, %Error{} = error, context} ->
respond_error(conn, error, options, context)
end
stop_metadata =
Map.merge(metadata, %{status: result.status, error_tag: error_tag(result)})
:telemetry.execute(
options.telemetry_prefix ++ [:request, :stop],
%{duration: System.monotonic_time() - start_time},
stop_metadata
)
result
catch
kind, reason ->
:telemetry.execute(
options.telemetry_prefix ++ [:request, :exception],
%{duration: System.monotonic_time() - start_time},
Map.merge(metadata, %{kind: kind, reason: reason, stacktrace: __STACKTRACE__})
)
:erlang.raise(kind, reason, __STACKTRACE__)
end
end
defp error_tag(%Plug.Conn{} = conn) do
case Plug.Conn.get_resp_header(conn, "x-image-plug-error") do
[tag | _] -> tag
[] -> nil
end
end
defp run(conn, options) do
with :ok <- verify_signature(conn, options) |> wrap_unit(),
{:ok, parsed} <- wrap(options.provider.parse(conn, options.provider_options)) do
case parsed do
{:info, kind, source} ->
run_info(conn, kind, source, options)
_ ->
run_render(conn, parsed, options)
end
end
end
defp run_render(conn, parsed, options) do
with {:ok, pipeline, source} <- wrap(resolve(parsed, options)),
{:ok, normalised} <- wrap(Pipeline.Normaliser.normalise(pipeline)),
{:ok, image, meta} <-
wrap(options.source_resolver.load(source, options.source_resolver_options)) do
chosen_format = chosen_format(normalised, accept_header(conn), meta)
cache = Image.Plug.Cache.compute(meta, normalised, chosen_format)
if Image.Plug.Cache.fresh?(conn, cache.etag) do
{:ok, send_not_modified(conn, cache)}
else
case run_encode(conn, normalised, image, meta, options, cache) do
{:ok, _} = ok -> ok
{:error, %Error{} = error} -> {:error, error, %{image: image, meta: meta}}
end
end
end
end
# Info-document requests (currently only IIIF Image API 3.0
# `info.json`). Loads the source long enough to read its
# dimensions, builds the JSON document, and sends it as
# `application/ld+json` per the IIIF spec. No transform happens —
# the source bytes are not encoded into the response.
defp run_info(conn, :iiif_image_info, source, options) do
with {:ok, image, _meta} <-
wrap(options.source_resolver.load(source, options.source_resolver_options)) do
width = Image.width(image)
height = Image.height(image)
id = canonical_iiif_id(conn)
doc = Image.Plug.Provider.IIIF.InfoJson.build(id, {width, height})
body = :json.encode(doc) |> IO.iodata_to_binary()
conn
|> Plug.Conn.put_resp_content_type("application/ld+json")
|> Plug.Conn.put_resp_header("link", iiif_profile_link())
|> Plug.Conn.put_resp_header("cache-control", "public, max-age=86400")
|> Plug.Conn.send_resp(200, body)
|> then(&{:ok, &1})
end
end
# The IIIF spec wants the `id` to be the canonical URL of the
# image service — i.e. the request URL with `/info.json` stripped.
defp canonical_iiif_id(%Plug.Conn{} = conn) do
full = Plug.Conn.request_url(conn)
String.replace_suffix(full, "/info.json", "")
end
# Per IIIF Image API 3.0 §6, servers SHOULD include a `Link`
# header pointing to the profile URI in info.json responses.
defp iiif_profile_link do
~s(<http://iiif.io/api/image/3/level2.json>;rel="profile")
end
# Lifts `{:ok, ...}` and `{:error, %Error{}}` into the
# `{:error, error, nil}` shape used by `respond_error/4` for
# errors that occur before the source is loaded.
defp wrap({:ok, _} = ok), do: ok
defp wrap({:ok, _, _} = ok), do: ok
defp wrap({:ok, _, _, _} = ok), do: ok
defp wrap({:error, %Error{} = error}), do: {:error, error, nil}
defp wrap_unit(:ok), do: :ok
defp wrap_unit({:error, %Error{} = error}), do: {:error, error, nil}
# Verifies the request signature when the plug is configured with
# `:signing`. Returns `:ok` when no signing config is present (the
# default), when a valid signature is supplied, or when signatures
# are not required and none was supplied.
defp verify_signature(_conn, %{signing: nil}), do: :ok
defp verify_signature(%Plug.Conn{} = conn, %{signing: %{keys: keys} = signing}) do
Image.Plug.Signing.verify(
request_path_with_query(conn),
keys,
required?: Map.get(signing, :required?, false)
)
end
defp request_path_with_query(%Plug.Conn{request_path: path, query_string: ""}), do: path
defp request_path_with_query(%Plug.Conn{request_path: path, query_string: q}),
do: "#{path}?#{q}"
defp run_encode(conn, normalised, image, meta, options, cache) do
interpreter_options = [resolve_layer_source: layer_source_resolver(options)]
encode_options = [
source_content_type: meta.content_type,
accept: accept_header(conn)
]
with {:ok, transformed} <-
Pipeline.Interpreter.execute(normalised, image, interpreter_options),
{:ok, body, content_type, extra_headers} <-
normalise_encode(
Pipeline.Encoder.encode(transformed, normalised.output, encode_options)
) do
{:ok, send_body(conn, body, content_type, extra_headers, cache)}
end
end
# Picks the format the encoder will actually emit, used to compute
# a cache key that differs across negotiated outputs. Mirrors the
# encoder's own selection logic.
defp chosen_format(
%Pipeline{output: %Image.Plug.Pipeline.Ops.Format{type: :auto}},
accept,
meta
) do
cond do
Image.Plug.Capabilities.avif_write?() and is_binary(accept) and
String.contains?(accept, "image/avif") ->
:avif
is_binary(accept) and
(String.contains?(accept, "image/webp") or String.contains?(accept, "image/*") or
String.contains?(accept, "*/*")) ->
:webp
true ->
case Map.get(meta, :content_type, "image/jpeg") do
"image/png" -> :png
"image/webp" -> :webp
"image/avif" -> :avif
_ -> :jpeg
end
end
end
defp chosen_format(
%Pipeline{output: %Image.Plug.Pipeline.Ops.Format{type: type}},
_accept,
_meta
),
do: type
defp normalise_encode({:ok, body, content_type}), do: {:ok, body, content_type, []}
defp normalise_encode({:ok, body, content_type, headers}),
do: {:ok, body, content_type, headers}
defp normalise_encode({:error, _} = error), do: error
defp accept_header(conn) do
case Plug.Conn.get_req_header(conn, "accept") do
[value | _] -> value
[] -> nil
end
end
# Closure passed to the interpreter so a `Draw` op can resolve its
# nested `%Source{}`s using the same source resolver pipeline as
# the base image. Returns `{:ok, %Vix.Vips.Image{}}` or
# `{:error, _}`.
defp layer_source_resolver(options) do
fn source ->
case options.source_resolver.load(source, options.source_resolver_options) do
{:ok, image, _meta} -> {:ok, image}
{:error, _} = error -> error
end
end
end
defp resolve({:pipeline, pipeline, source}, _options) do
{:ok, pipeline, source}
end
defp resolve({:passthrough, source}, _options) do
# Passthrough still runs through the interpreter (a no-op pass)
# and the encoder, so a downstream cache sees a consistent
# response shape regardless of whether the URL carried any
# transforms. The encoder doesn't honour `Format{type: :auto}`
# without an explicit `Accept` negotiation context, so we
# substitute `:jpeg` here.
pipeline =
Pipeline.new(provider: Image.Plug.Provider.Cloudflare)
|> Pipeline.put_output(%Image.Plug.Pipeline.Ops.Format{type: :jpeg, quality: 85})
{:ok, pipeline, source}
end
defp resolve({:variant, name, overrides, source}, options) do
case options.variant_store.get(name, options.variant_store_options) do
{:ok, variant} ->
merged = apply_variant_overrides(variant.pipeline, overrides)
{:ok, merged, source}
{:error, :not_found} ->
{:error, Error.new(:variant_not_found, "no such variant", details: %{name: name})}
end
end
# Per-request overrides arrive as a keyword list of
# `Image.Plug.Pipeline` field replacements. The override shape
# we currently honour is `{:output, %Ops.Format{}}` to swap the
# output format. More override shapes land alongside the providers
# that emit them.
defp apply_variant_overrides(pipeline, []), do: pipeline
defp apply_variant_overrides(pipeline, overrides) when is_list(overrides) do
Enum.reduce(overrides, pipeline, fn
{:output, %Image.Plug.Pipeline.Ops.Format{} = format}, acc ->
Image.Plug.Pipeline.put_output(acc, format)
{:on_error, value}, acc ->
%{acc | on_error: value}
_other, acc ->
acc
end)
end
defp send_body(conn, {:stream, stream}, content_type, extra_headers, cache) do
conn
|> put_cache_headers(cache)
|> put_extra_headers(extra_headers)
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_chunked(200)
|> stream_chunks(stream)
end
defp send_body(conn, {:bytes, bytes}, content_type, extra_headers, cache) do
conn
|> put_cache_headers(cache)
|> put_extra_headers(extra_headers)
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_resp(200, bytes)
end
defp send_not_modified(conn, cache) do
conn
|> put_cache_headers(cache)
|> Plug.Conn.send_resp(304, "")
end
defp put_cache_headers(conn, cache) do
conn
|> Plug.Conn.put_resp_header("etag", cache.etag)
|> Plug.Conn.put_resp_header("cache-control", cache.cache_control)
|> put_vary(cache)
|> put_last_modified(cache)
end
defp put_vary(conn, %{vary: [_ | _] = vary}) do
Plug.Conn.put_resp_header(conn, "vary", Enum.join(vary, ", "))
end
defp put_last_modified(conn, %{last_modified: %DateTime{} = ts}) do
Plug.Conn.put_resp_header(conn, "last-modified", format_http_date(ts))
end
defp put_last_modified(conn, _cache), do: conn
defp format_http_date(%DateTime{} = ts) do
# RFC 7231 IMF-fixdate: "Sun, 06 Nov 1994 08:49:37 GMT"
Calendar.strftime(ts, "%a, %d %b %Y %H:%M:%S GMT")
end
defp put_extra_headers(conn, headers) do
Enum.reduce(headers, conn, fn {key, value}, acc ->
Plug.Conn.put_resp_header(acc, key, value)
end)
end
defp stream_chunks(conn, stream) do
Enum.reduce_while(stream, conn, fn chunk, acc ->
case Plug.Conn.chunk(acc, chunk) do
{:ok, acc} ->
{:cont, acc}
{:error, :closed} ->
{:halt, acc}
{:error, reason} ->
Logger.warning("image_plug: stream chunk failed: #{inspect(reason)}")
{:halt, acc}
end
end)
end
defp respond_error(conn, %Error{} = error, options, context) do
log_error(error)
case resolved_on_error(options.on_error) do
:raise ->
raise "image_plug: #{error.tag}: #{error.message}"
:fallback_to_source ->
case context do
%{image: image, meta: meta} ->
send_fallback_source(conn, error, image, meta)
_ ->
send_status_error(conn, error)
end
:render_error_image ->
send_error_image(conn, error)
:status_text ->
send_status_error(conn, error)
{:status, code} when is_integer(code) ->
send_status_error(conn, %{error | tag: error.tag}, code)
end
end
defp resolved_on_error(:auto) do
env =
Application.get_env(:image_plug, :env) ||
(Code.ensure_loaded?(Mix) && function_exported?(Mix, :env, 0) && apply(Mix, :env, []))
if env == :prod, do: :fallback_to_source, else: :render_error_image
end
defp resolved_on_error(other), do: other
defp send_status_error(conn, error, status \\ nil) do
status = status || Error.status(error)
body = "image_plug: #{error.tag}: #{error.message}"
conn
|> Plug.Conn.put_resp_content_type("text/plain")
|> Plug.Conn.put_resp_header("x-image-plug-error", to_string(error.tag))
|> Plug.Conn.put_resp_header("cache-control", "no-store")
|> Plug.Conn.send_resp(status, body)
end
defp send_fallback_source(conn, error, image, meta) do
case Pipeline.Encoder.encode(image, source_format(meta), buffer: :bytes) do
{:ok, {:bytes, bytes}, content_type} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.put_resp_header("x-image-plug-error", to_string(error.tag))
|> Plug.Conn.put_resp_header("cache-control", "no-store")
|> Plug.Conn.send_resp(200, bytes)
_ ->
send_status_error(conn, error)
end
end
defp source_format(meta) do
type =
case Map.get(meta, :content_type) do
"image/png" -> :png
"image/webp" -> :webp
"image/avif" -> :avif
_ -> :jpeg
end
%Image.Plug.Pipeline.Ops.Format{type: type, quality: 85, metadata: :keep}
end
defp send_error_image(conn, error) do
body = render_error_placeholder(error)
conn
|> Plug.Conn.put_resp_content_type("image/png")
|> Plug.Conn.put_resp_header("x-image-plug-error", to_string(error.tag))
|> Plug.Conn.put_resp_header("cache-control", "no-store")
|> Plug.Conn.send_resp(200, body)
end
# Renders a simple PNG placeholder (400×300) with the error tag and
# message painted onto a high-contrast background. Built via SVG so
# we don't drag in font dependencies or layout libraries.
defp render_error_placeholder(%Error{tag: tag, message: message}) do
svg = """
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<rect width="100%" height="100%" fill="#fde2e2"/>
<rect x="2" y="2" width="396" height="296" fill="none" stroke="#b71c1c" stroke-width="4"/>
<text x="20" y="60" font-family="sans-serif" font-size="22" fill="#b71c1c" font-weight="700">image_server error</text>
<text x="20" y="100" font-family="monospace" font-size="16" fill="#7f1010">#{tag}</text>
<text x="20" y="140" font-family="sans-serif" font-size="14" fill="#7f1010">#{escape_xml(message)}</text>
</svg>
"""
case Image.from_svg(svg) do
{:ok, image} ->
case Image.write(image, :memory, suffix: ".png") do
{:ok, bytes} -> bytes
{:error, _} -> fallback_error_bytes()
end
{:error, _} ->
fallback_error_bytes()
end
rescue
_ -> fallback_error_bytes()
end
defp escape_xml(value) when is_binary(value) do
value
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.slice(0, 120)
end
defp fallback_error_bytes do
# 1×1 transparent PNG used when even the SVG placeholder fails.
<<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1,
13, 10, 45, 180, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130>>
end
defp log_error(%Error{} = error) do
Logger.error(fn ->
"image_plug request failed: tag=#{error.tag} message=#{error.message} " <>
"details=#{inspect(error.details)}"
end)
end
# ---------- public helpers (formerly Image.Server top-level) ----------
@version Mix.Project.config()[:version]
@doc """
Returns the library version as a string.
### Returns
* A semver-like version string such as `"0.1.0-dev"`.
### Examples
iex> is_binary(Image.Plug.version())
true
"""
@spec version() :: String.t()
def version, do: @version
@doc """
Returns the default telemetry prefix used by the request plug.
### Returns
* The list of atoms `[:image_plug]`.
### Examples
iex> Image.Plug.default_telemetry_prefix()
[:image_plug]
"""
@spec default_telemetry_prefix() :: [atom(), ...]
def default_telemetry_prefix, do: [:image_plug]
alias Image.Plug.{Pipeline, Variant}
@default_store Image.Plug.VariantStore.ETS
@doc """
Inserts or updates a variant.
### Arguments
* `name_or_variant` is either a variant name string or a complete
`Image.Plug.Variant` struct.
* When the first argument is a name, the second argument is either
a Cloudflare-style options string, an `Image.Plug.Pipeline`
struct, or a `{provider_module, options_string}` tuple.
### Options
* `:store` — `{module, options}` tuple identifying the store.
Defaults to `{Image.Plug.VariantStore.ETS, []}`.
* `:metadata` — arbitrary metadata map stored on the variant.
* `:never_require_signed_urls?` — boolean, defaults to `false`.
### Returns
* `{:ok, variant}` on success.
* `{:error, reason}` on failure (e.g. invalid options string).
"""
@spec put_variant(Variant.t() | String.t(), term(), keyword()) ::
{:ok, Variant.t()} | {:error, term()}
def put_variant(name_or_variant, definition_or_options \\ [], options \\ [])
def put_variant(%Variant{} = variant, options, _ignored) when is_list(options) do
{store, store_options} = store(options)
store.put(variant, Keyword.merge(store_options, Keyword.take(options, [:server, :table])))
end
def put_variant(name, definition, options) when is_binary(name) do
case to_variant(name, definition, options) do
{:ok, variant} -> put_variant(variant, options, [])
{:error, _} = error -> error
end
end
@doc """
Fetches a variant by name.
Returns `{:ok, variant}` or `{:error, :not_found}`.
### Examples
iex> case Image.Plug.get_variant("public") do
...> {:ok, variant} -> variant.name
...> {:error, _} -> nil
...> end
"public"
"""
@spec get_variant(String.t(), keyword()) ::
{:ok, Variant.t()} | {:error, :not_found}
def get_variant(name, options \\ []) when is_binary(name) do
{store, store_options} = store(options)
store.get(name, Keyword.merge(store_options, Keyword.take(options, [:table])))
end
@doc """
Deletes a variant by name.
### Returns
* `:ok` on success.
* `{:error, :not_found}` if the variant does not exist.
"""
@spec delete_variant(String.t(), keyword()) :: :ok | {:error, :not_found}
def delete_variant(name, options \\ []) when is_binary(name) do
{store, store_options} = store(options)
store.delete(name, Keyword.merge(store_options, Keyword.take(options, [:server])))
end
@doc """
Lists every variant in the store.
### Returns
* `{:ok, [variant]}` — the order is store-defined (the default ETS
store sorts by name).
"""
@spec list_variants(keyword()) :: {:ok, [Variant.t()]}
def list_variants(options \\ []) do
{store, store_options} = store(options)
store.list(Keyword.merge(store_options, Keyword.take(options, [:table])))
end
defp store(options) do
case Keyword.get(options, :store, {@default_store, []}) do
module when is_atom(module) -> {module, []}
{module, opts} when is_atom(module) and is_list(opts) -> {module, opts}
end
end
defp to_variant(name, %Pipeline{} = pipeline, options) do
{:ok,
%Variant{
name: name,
pipeline: pipeline,
options: nil,
metadata: Keyword.get(options, :metadata, %{}),
never_require_signed_urls?: Keyword.get(options, :never_require_signed_urls?, false)
}}
end
defp to_variant(name, options_string, options) when is_binary(options_string) do
to_variant(name, {Image.Plug.Provider.Cloudflare, options_string}, options)
end
defp to_variant(name, {provider, options_string}, options)
when is_atom(provider) and is_binary(options_string) do
case parse_with(provider, options_string) do
{:ok, pipeline} ->
{:ok,
%Variant{
name: name,
pipeline: pipeline,
options: options_string,
metadata: Keyword.get(options, :metadata, %{}),
never_require_signed_urls?: Keyword.get(options, :never_require_signed_urls?, false)
}}
{:error, _} = error ->
error
end
end
defp to_variant(_name, other, _options) do
{:error,
Image.Plug.Error.new(
:invalid_option,
"variant definition must be an options string, a Pipeline, or a {provider, string} tuple",
details: %{got: inspect(other)}
)}
end
defp parse_with(Image.Plug.Provider.Cloudflare, options_string) do
Image.Plug.Provider.Cloudflare.Options.parse(options_string)
end
defp parse_with(provider, _options_string) do
{:error,
Image.Plug.Error.new(
:invalid_option,
"no options-string parser registered for provider",
details: %{provider: provider}
)}
end
end