defmodule Image.Plug.SourceResolver.File do @moduledoc """ Source resolver that reads images from a configured root directory. Maps `%Image.Plug.Source{kind: :path, ref: "/foo/bar.jpg"}` onto `/foo/bar.jpg`. Only `:path` sources are accepted. Decoding is streaming-friendly: the file path is passed straight to `Image.open/2`, which lets libvips mmap or progressively decode rather than slurping the file into a binary first. ### Configuration * `:root` (required) — absolute path to the directory under which source files live. Must exist at boot time. Symlinks pointing outside the root are rejected at request time. ### Security Path-traversal is blocked at two levels: `Image.Plug.Source.path/1` rejects `..` segments before the source even reaches the resolver, and the resolver re-validates that the canonical resolved path is still inside the root. """ @behaviour Image.Plug.SourceResolver alias Image.Plug.{Error, Source} @content_types %{ ".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" } @impl Image.Plug.SourceResolver def load(%Source{kind: :path, ref: ref} = _source, options) when is_binary(ref) do with {:ok, root} <- fetch_root(options), {:ok, absolute_path} <- resolve(root, ref), {:ok, %{type: file_type, mtime: mtime, size: size}} <- file_stat(absolute_path), :ok <- ensure_regular(file_type, absolute_path), {:ok, image} <- open(absolute_path) do meta = %{ content_type: content_type_for(absolute_path), etag_seed: IO.iodata_to_binary([ absolute_path, "|", Integer.to_string(size), "|", Integer.to_string(mtime_to_unix(mtime)) ]), last_modified: mtime_to_datetime(mtime), byte_size: size } {:ok, image, meta} end end def load(%Source{kind: kind} = _source, _options) do {:error, Error.new(:invalid_option, "SourceResolver.File only handles :path sources", details: %{got_kind: kind} )} end defp fetch_root(options) do case Keyword.fetch(options, :root) do {:ok, root} when is_binary(root) -> if Path.type(root) == :absolute do {:ok, Path.expand(root)} else {:error, Error.new(:invalid_option, "SourceResolver.File :root must be an absolute path", details: %{root: root} )} end _ -> {:error, Error.new(:invalid_option, "SourceResolver.File requires a :root option")} end end defp resolve(root, "/" <> rest) do candidate = Path.expand(rest, root) if String.starts_with?(candidate, root <> "/") or candidate == root do {:ok, candidate} else {:error, Error.new(:invalid_option, "resolved source path escapes the configured root", details: %{path: rest} )} end end defp resolve(_root, ref) do {:error, Error.new(:invalid_option, "source path must be absolute", details: %{path: ref})} end defp file_stat(absolute_path) do case File.stat(absolute_path, time: :posix) do {:ok, stat} -> {:ok, %{type: stat.type, mtime: stat.mtime, size: stat.size}} {:error, :enoent} -> {:error, Error.new(:source_not_found, "source file does not exist", details: %{path: absolute_path} )} {:error, reason} -> {:error, Error.new(:source_fetch_error, "could not stat source file", details: %{path: absolute_path, reason: reason} )} end end defp ensure_regular(:regular, _absolute_path), do: :ok defp ensure_regular(other, absolute_path) do {:error, Error.new(:invalid_option, "source path is not a regular file", details: %{path: absolute_path, type: other} )} end # Mirrors the canonical streaming pipeline shape used in the # `Image` library's own test suite (`stream_image_test.exs`): # # path # |> File.stream!(2048, []) # |> Image.open() # |> ...transforms... # |> Image.stream!(suffix: ".jpg") # |> Enum.reduce_while(conn, &Plug.Conn.chunk(&2, &1)) # # The 2048-byte chunk size matches Image's documented examples; # libvips reads from the enum on demand, so memory use stays # bounded regardless of source size. defp open(absolute_path) do stream = File.stream!(absolute_path, 2048, []) case Image.open(stream) do {:ok, image} -> {:ok, image} {:error, reason} -> {:error, Error.new(:unsupported_source_format, "could not decode source image", details: %{path: absolute_path, reason: format_reason(reason)} )} end end defp content_type_for(path) do ext = path |> Path.extname() |> String.downcase() Map.get(@content_types, ext, "application/octet-stream") end defp mtime_to_unix(unix) when is_integer(unix), do: unix defp mtime_to_datetime(unix) when is_integer(unix) do case DateTime.from_unix(unix) do {:ok, datetime} -> datetime _ -> nil end end # Image.open/2 returns `{:error, %Image.Error{message: binary}}`. defp format_reason(%{message: message}) when is_binary(message), do: message end