defmodule Image.Plug.Admin do
@moduledoc """
HTTP admin surface for variant CRUD.
Mount this plug under whatever path your host exposes (the admin
routes are intentionally relative — no baked-in prefix).
| Method | Path | Action |
| -------- | -------- | ------ |
| `GET` | `/` | List all variants. |
| `GET` | `/:name` | Fetch one variant. |
| `POST` | `/` | Create a variant. 409 on name conflict. |
| `PUT` | `/:name` | Upsert a variant. |
| `PATCH` | `/:name` | Partial update of an existing variant. |
| `DELETE` | `/:name` | Delete a variant. |
Request bodies use the canonical variant JSON shape:
{
"name": "thumbnail",
"options": "width=200,height=200,fit=cover,format=webp",
"metadata": {"description": "card thumbnail"},
"never_require_signed_urls": false
}
`options` is a provider-specific options string parsed by the
configured provider (Cloudflare by default).
### Configuration
* `:provider` — module used to parse the `options` string.
Defaults to `Image.Plug.Provider.Cloudflare`.
* `:variant_store` — `{module, options}` tuple identifying the
variant store. Defaults to `{Image.Plug.VariantStore.ETS, []}`.
### Authentication
This plug does **not** authenticate or authorise requests. Wrap it
in your host's auth pipeline (e.g. a `:basic_auth` plug or a
Phoenix `:require_admin` pipeline) before exposing it publicly.
"""
@behaviour Plug
alias Image.Plug.{Error, Pipeline, Variant}
require Logger
@impl Plug
def init(options) when is_list(options) do
%{
provider: Keyword.get(options, :provider, Image.Plug.Provider.Cloudflare),
store:
Keyword.get(options, :variant_store, {Image.Plug.VariantStore.ETS, []})
|> normalise_store()
}
end
defp normalise_store({module, opts}) when is_atom(module) and is_list(opts), do: {module, opts}
defp normalise_store(module) when is_atom(module), do: {module, []}
@impl Plug
def call(%Plug.Conn{} = conn, %{} = config) do
case route(conn) do
{:list} -> handle_list(conn, config)
{:get, name} -> handle_get(conn, config, name)
{:create} -> handle_create(conn, config)
{:upsert, name} -> handle_upsert(conn, config, name)
{:patch, name} -> handle_patch(conn, config, name)
{:delete, name} -> handle_delete(conn, config, name)
:unknown -> respond_error(conn, Error.new(:malformed_url, "no admin route matched"))
end
end
defp route(%Plug.Conn{method: "GET", path_info: []}), do: {:list}
defp route(%Plug.Conn{method: "GET", path_info: [name]}), do: {:get, name}
defp route(%Plug.Conn{method: "POST", path_info: []}), do: {:create}
defp route(%Plug.Conn{method: "PUT", path_info: [name]}), do: {:upsert, name}
defp route(%Plug.Conn{method: "PATCH", path_info: [name]}), do: {:patch, name}
defp route(%Plug.Conn{method: "DELETE", path_info: [name]}), do: {:delete, name}
defp route(_conn), do: :unknown
# ---------- handlers ----------
defp handle_list(conn, config) do
{store_module, store_options} = config.store
{:ok, variants} = store_module.list(store_options)
body = %{"result" => Enum.map(variants, &variant_to_json/1)}
json_response(conn, 200, body)
end
defp handle_get(conn, config, name) do
{store_module, store_options} = config.store
case store_module.get(name, store_options) do
{:ok, variant} ->
json_response(conn, 200, variant_to_json(variant))
{:error, :not_found} ->
respond_error(conn, Error.new(:variant_not_found, "no such variant"))
end
end
defp handle_create(conn, config) do
{store_module, store_options} = config.store
with {:ok, params} <- read_json(conn),
{:ok, name} <- fetch_name(params),
{:error, :not_found} <- store_module.get(name, store_options),
{:ok, variant} <- build_variant(name, params, config) do
put_and_respond(conn, store_module, store_options, variant, 201)
else
{:ok, %Variant{}} ->
respond_error(conn, Error.new(:variant_already_exists, "variant already exists"))
{:error, %Error{} = error} ->
respond_error(conn, error)
end
end
defp handle_upsert(conn, config, name) do
{store_module, store_options} = config.store
with {:ok, params} <- read_json(conn),
params = Map.put(params, "name", name),
{:ok, variant} <- build_variant(name, params, config) do
put_and_respond(conn, store_module, store_options, variant, 200)
else
{:error, %Error{} = error} -> respond_error(conn, error)
end
end
defp handle_patch(conn, config, name) do
{store_module, store_options} = config.store
with {:ok, existing} <- store_module.get(name, store_options),
{:ok, params} <- read_json(conn),
{:ok, merged} <- merge_variant(existing, params, config) do
put_and_respond(conn, store_module, store_options, merged, 200)
else
{:error, :not_found} ->
respond_error(conn, Error.new(:variant_not_found, "no such variant"))
{:error, %Error{} = error} ->
respond_error(conn, error)
end
end
defp handle_delete(conn, config, name) do
{store_module, store_options} = config.store
case store_module.delete(name, store_options) do
:ok ->
json_response(conn, 200, %{"result" => %{"deleted" => name}})
{:error, :not_found} ->
respond_error(conn, Error.new(:variant_not_found, "no such variant"))
end
end
defp put_and_respond(conn, store_module, store_options, variant, status) do
case store_module.put(variant, store_options) do
{:ok, stored} ->
json_response(conn, status, variant_to_json(stored))
{:error, reason} ->
respond_error(
conn,
Error.new(:internal, "store rejected variant", details: %{reason: inspect(reason)})
)
end
end
# ---------- variant <-> JSON ----------
defp build_variant(name, params, config) do
options_string = Map.get(params, "options", "")
case parse_options(config.provider, options_string) do
{:ok, pipeline} ->
{:ok,
%Variant{
name: name,
pipeline: pipeline,
options: options_string,
metadata: Map.get(params, "metadata", %{}) |> ensure_map(),
never_require_signed_urls?: !!Map.get(params, "never_require_signed_urls", false)
}}
{:error, _} = error ->
error
end
end
defp merge_variant(%Variant{} = existing, params, config) do
options_string = Map.get(params, "options", existing.options)
with {:ok, pipeline} <- parse_options(config.provider, options_string || "") do
{:ok,
%Variant{
existing
| pipeline: pipeline,
options: options_string,
metadata: Map.get(params, "metadata", existing.metadata) |> ensure_map(),
never_require_signed_urls?:
case Map.fetch(params, "never_require_signed_urls") do
{:ok, value} -> !!value
:error -> existing.never_require_signed_urls?
end
}}
end
end
defp parse_options(Image.Plug.Provider.Cloudflare, options_string) do
Image.Plug.Provider.Cloudflare.Options.parse(options_string)
end
defp parse_options(provider, _options_string) do
{:error,
Error.new(
:invalid_option,
"no options-string parser registered for provider",
details: %{provider: provider}
)}
end
defp variant_to_json(%Variant{} = variant) do
%{
"name" => variant.name,
"options" => variant.options,
"metadata" => variant.metadata,
"never_require_signed_urls" => variant.never_require_signed_urls?,
"inserted_at" => datetime_to_iso(variant.inserted_at),
"updated_at" => datetime_to_iso(variant.updated_at),
"ops" => Enum.map(variant.pipeline.ops, &op_to_json/1),
"output" => format_to_json(variant.pipeline.output)
}
end
defp op_to_json(%struct{} = op) do
%{
"kind" => struct |> Module.split() |> List.last(),
"fields" =>
op
|> Map.from_struct()
|> Enum.into(%{}, fn {k, v} -> {Atom.to_string(k), inspect_value(v)} end)
}
end
defp format_to_json(%Pipeline.Ops.Format{} = format) do
%{
"type" => Atom.to_string(format.type),
"quality" => format.quality,
"metadata" => Atom.to_string(format.metadata),
"anim" => format.anim?,
"dpr" => format.dpr
}
end
defp inspect_value(nil), do: nil
defp inspect_value(value) when is_boolean(value), do: value
defp inspect_value(value) when is_atom(value), do: Atom.to_string(value)
defp inspect_value(value) when is_number(value), do: value
defp inspect_value(value) when is_binary(value), do: value
defp inspect_value(value), do: inspect(value)
defp datetime_to_iso(nil), do: nil
defp datetime_to_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp ensure_map(value) when is_map(value), do: value
defp ensure_map(_other), do: %{}
# ---------- request body / response ----------
defp read_json(conn) do
case Plug.Conn.read_body(conn) do
{:ok, body, _conn} ->
case body do
"" ->
{:ok, %{}}
binary ->
try do
{:ok, :json.decode(binary)}
rescue
_ -> {:error, Error.new(:invalid_option, "request body is not valid JSON")}
end
end
{:error, _reason} ->
{:error, Error.new(:invalid_option, "could not read request body")}
end
end
defp fetch_name(params) do
case Map.fetch(params, "name") do
{:ok, name} when is_binary(name) and name != "" -> {:ok, name}
_ -> {:error, Error.new(:invalid_option, "request body must include a non-empty `name`")}
end
end
defp json_response(conn, status, body) do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(status, IO.iodata_to_binary(:json.encode(body)))
end
defp respond_error(conn, %Error{} = error) do
Logger.warning(fn ->
"image_server admin: tag=#{error.tag} message=#{error.message} details=#{inspect(error.details)}"
end)
body = %{"error" => Atom.to_string(error.tag), "message" => error.message}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.put_resp_header("x-image-plug-error", to_string(error.tag))
|> Plug.Conn.send_resp(Error.status(error), IO.iodata_to_binary(:json.encode(body)))
end
end