defmodule Image.Plug.SourceResolver.HTTP do @moduledoc """ Source resolver that streams images from `http(s)://` URLs. Decoding is streaming-friendly: the body flows from the socket into libvips chunk-by-chunk via `Image.from_req_stream/2`, which uses `Req.get/2` with `into: :self` and feeds bytes into `Vix.Vips.Image.new_from_enum/1`. ### Configuration * `:allowed_hosts` (required) — list of hostname strings the resolver will fetch from. Hosts not on the list are rejected with `:invalid_option`. Pass `:any` to disable the allow-list (only sensible when this resolver sits behind a host-supplied auth/auditing layer). * `:timeout` — milliseconds to wait between chunks. Defaults to `5_000` (the `Image.from_req_stream/2` default). ### Limitations (v0.1) * The streaming decode path does not surface response headers to the caller, so the resolver cannot populate `:last_modified` or forward an upstream `ETag`. The `etag_seed` is derived from the request URL itself, which gives a stable per-URL ETag without a second pass over the body. A future milestone may switch to a two-stage HEAD-then-GET when freshness signals are required. * Response body size is bounded by whatever limits the configured `Req` request honours; we do not impose an additional cap in this resolver. * Requires the optional `:req` dependency. If `Req` is not loaded the resolver returns `:not_implemented` at request time. """ @behaviour Image.Plug.SourceResolver alias Image.Plug.{Error, Source} @impl Image.Plug.SourceResolver def load(%Source{kind: :url, ref: url}, options) when is_binary(url) do with :ok <- ensure_req_loaded(), :ok <- ensure_host_allowed(url, Keyword.fetch!(options, :allowed_hosts)), {:ok, image} <- open_stream(url, options) do meta = %{ content_type: content_type_for(url), etag_seed: :crypto.hash(:sha256, url), byte_size: 0 } {:ok, image, meta} end end def load(%Source{kind: kind}, _options) do {:error, Error.new(:invalid_option, "SourceResolver.HTTP only handles :url sources", details: %{got_kind: kind} )} end defp ensure_req_loaded do if Code.ensure_loaded?(Req) and function_exported?(Image, :from_req_stream, 2) do :ok else {:error, Error.new( :not_implemented, "SourceResolver.HTTP requires the optional :req dependency to be loaded" )} end end defp ensure_host_allowed(_url, :any), do: :ok defp ensure_host_allowed(url, allowed_hosts) when is_list(allowed_hosts) do case URI.parse(url) do %URI{host: host} when is_binary(host) -> if host in allowed_hosts do :ok else {:error, Error.new(:invalid_option, "source host is not on the allow-list", details: %{host: host, allowed: allowed_hosts} )} end _ -> {:error, Error.new(:invalid_option, "could not extract host from URL")} end end defp open_stream(url, options) do timeout = Keyword.get(options, :timeout, 5_000) case Image.from_req_stream(url, timeout: timeout) do {:ok, image} -> {:ok, image} {:error, reason} -> {:error, Error.new(:source_fetch_error, "failed to fetch source over HTTP", details: %{url: url, reason: inspect(reason)} )} end end defp content_type_for(url) do case url |> URI.parse() |> Map.get(:path, "") |> Path.extname() |> String.downcase() do ".jpg" -> "image/jpeg" ".jpeg" -> "image/jpeg" ".png" -> "image/png" ".webp" -> "image/webp" ".avif" -> "image/avif" ".gif" -> "image/gif" ".svg" -> "image/svg+xml" ".tif" -> "image/tiff" ".tiff" -> "image/tiff" ".heic" -> "image/heic" _ -> "application/octet-stream" end end end