Skip to main content

lib/image/plug/source_resolver/file.ex

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
  `<root>/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}

  @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
    case Path.extname(path) |> 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

  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