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 `///` 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(;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 = """ image_server error #{tag} #{escape_xml(message)} """ 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