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