Skip to main content

lib/image/plug/source.ex

defmodule Image.Plug.Source do
  @moduledoc """
  Reference to a source image. Produced by a provider, consumed by a
  source resolver.

  A source is intentionally a thin tagged reference. The provider names
  *what* the source is (an absolute path, an absolute URL, or a hosted
  asset id) and the source resolver decides *where the bytes come from*.

  Sources never hold image bytes themselves.
  """

  @typedoc """
  The kind of source. Determines which `Image.Plug.SourceResolver`
  implementation handles it.

  * `:path` — `ref` is an absolute path string. Resolved against a
    configured root directory by `Image.Plug.SourceResolver.File`.

  * `:url` — `ref` is an absolute `http(s)://` URL. Resolved by
    `Image.Plug.SourceResolver.HTTP` against an allow-list.

  * `:hosted` — `ref` is a `{account_hash, image_id}` tuple. Resolved
    by `Image.Plug.SourceResolver.Hosted` against a host-supplied
    asset table.
  """
  @type kind :: :path | :url | :hosted

  @type ref :: String.t() | {String.t(), String.t()}

  @type t :: %__MODULE__{
          kind: kind(),
          ref: ref(),
          headers: %{optional(String.t()) => String.t()}
        }

  @enforce_keys [:kind, :ref]
  defstruct [:kind, :ref, headers: %{}]

  @doc """
  Builds a `:path` source.

  ### Arguments

  * `path` is an absolute path string (must start with `/`).

  ### Returns

  * `{:ok, source}` on success.

  * `{:error, %Image.Plug.Error{tag: :invalid_option}}` if the path is
    not absolute or contains `..` segments.

  ### Examples

      iex> {:ok, source} = Image.Plug.Source.path("/foo/bar.jpg")
      iex> source.kind
      :path

      iex> {:error, error} = Image.Plug.Source.path("relative.jpg")
      iex> error.tag
      :invalid_option

  """
  @spec path(String.t()) :: {:ok, t()} | {:error, Image.Plug.Error.t()}
  def path(path) when is_binary(path) do
    cond do
      not String.starts_with?(path, "/") ->
        {:error,
         Image.Plug.Error.new(:invalid_option, "source path must be absolute",
           details: %{path: path}
         )}

      ".." in Path.split(path) ->
        {:error,
         Image.Plug.Error.new(:invalid_option, "source path may not contain `..`",
           details: %{path: path}
         )}

      true ->
        {:ok, %__MODULE__{kind: :path, ref: path}}
    end
  end

  @doc """
  Builds a `:url` source.

  ### Arguments

  * `url` is an absolute `http://` or `https://` URL.

  ### Returns

  * `{:ok, source}` on success.

  * `{:error, %Image.Plug.Error{tag: :invalid_option}}` if the URL is
    missing a scheme or host.

  ### Examples

      iex> {:ok, source} = Image.Plug.Source.url("https://example.com/a.jpg")
      iex> source.kind
      :url

      iex> {:error, error} = Image.Plug.Source.url("not-a-url")
      iex> error.tag
      :invalid_option

  """
  @spec url(String.t()) :: {:ok, t()} | {:error, Image.Plug.Error.t()}
  def url(url) when is_binary(url) do
    case URI.parse(url) do
      %URI{scheme: scheme, host: host}
      when scheme in ["http", "https"] and is_binary(host) and host != "" ->
        {:ok, %__MODULE__{kind: :url, ref: url}}

      _ ->
        {:error,
         Image.Plug.Error.new(:invalid_option, "source url must be absolute http(s)",
           details: %{url: url}
         )}
    end
  end

  @doc """
  Builds a `:hosted` source.

  ### Arguments

  * `account_hash` is an opaque account identifier string.

  * `image_id` is an opaque image identifier string.

  ### Returns

  * An `Image.Plug.Source` struct (always succeeds).

  ### Examples

      iex> source = Image.Plug.Source.hosted("acct123", "img456")
      iex> source.kind
      :hosted
      iex> source.ref
      {"acct123", "img456"}

  """
  @spec hosted(String.t(), String.t()) :: t()
  def hosted(account_hash, image_id)
      when is_binary(account_hash) and is_binary(image_id) do
    %__MODULE__{kind: :hosted, ref: {account_hash, image_id}}
  end
end