defmodule SanityWebhookPlug do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
@behaviour Plug
require Logger
alias SanityWebhookPlug.Signature
@derive {Inspect, except: [:secret]}
@type t :: %__MODULE__{
body: binary() | nil,
computed: String.t(),
error: String.t() | false,
secret: String.t(),
hash: String.t(),
ts: pos_integer()
}
defstruct [:body, :computed, :secret, :hash, :ts, error: false]
@header "sanity-webhook-signature"
@plug_key :sanity_webhook_plug
@doc """
Initialize SanityWebhookPlug options.
"""
def init(opts) do
path_info = opts |> Keyword.fetch!(:at) |> String.split("/", trim: true)
handler = Keyword.fetch!(opts, :handler)
sanity_json = Keyword.get(opts, :json_decoder)
phoenix_json = Application.get_env(:phoenix, :json_library)
jason = if Code.ensure_loaded?(Jason), do: Jason
poison = if Code.ensure_loaded?(Poison), do: Poison
[
path_info,
handler,
Keyword.get(opts, :secret),
Keyword.take(opts, [:length, :read_length, :read_timeout]),
sanity_json || phoenix_json || jason || poison
]
end
@doc """
Process the conn for a Sanity Webhook and verify its authenticity.
"""
def call(conn, opts)
def call(%Plug.Conn{halted: true} = conn, _), do: conn
def call(%Plug.Conn{path_info: path_info} = conn, [path_info | opts]) do
call(conn, opts)
end
def call(conn, [handler, secret, read_opts, json_mod]) do
with {:ok, secret} <- get_secret(secret),
{:ok, conn, body} <- read_body(conn, read_opts),
{:ok, conn, {ts, hash}} <- get_signature(conn),
:ok <- verify(conn, hash, ts, body, secret),
{:ok, json} <- parse(json_mod, body, conn, ts, hash) do
conn
|> put_debug({hash, ts, nil}, hash, secret, false)
|> handler.handle_event(json)
|> Plug.Conn.halt()
else
{:error, error} ->
# Bad secret or bad JSON decoding
conn
|> put_debug(nil, nil, secret, error)
|> handle_error()
|> handler.handle_error(error)
{:error, error, conn} ->
# Bad body read or no header
conn
|> put_debug(nil, nil, secret, error)
|> handle_error()
|> handler.handle_error(error)
{:error, error, conn, components, computed} ->
# Bad signature
conn
|> put_debug(components, computed, secret, error)
|> handle_error()
|> handler.handle_error(error)
end
end
def call(conn, _opts), do: conn
defp parse(json_mod, body, conn, ts, hash) do
case json_mod.decode(body) do
{:ok, json} ->
{:ok, Map.merge(json, conn.query_params)}
{:error, error} ->
{:error, error, conn, {ts, hash, body}, nil}
end
end
@doc """
Get the Sanity Webhook debug information from the conn.
"""
@spec get_debug(Plug.Conn.t()) :: t()
def get_debug(conn), do: conn.private[@plug_key]
@doc """
Generate a request header tuple compatible with Sanity webhook systems.
Timestamp can be either a %DateTime{} or a unix timestamp in milliseconds.
{"sanity-webhook-signature", "ts=123,v1=abc123signature"}
"""
@spec make_header(DateTime.t() | pos_integer(), binary(), String.t()) ::
{String.t(), String.t()} | {:error, String.t(), nil}
def make_header(%DateTime{} = timestamp, payload, secret) do
make_header(DateTime.to_unix(timestamp, :millisecond), payload, secret)
end
def make_header(timestamp, payload, secret) when is_integer(timestamp) do
with {:ok, sig} <- Signature.compute(timestamp, payload, secret) do
{header(), "t=#{timestamp},v1=#{sig}"}
end
end
@doc """
The expected request header that contains the Sanity webhook signature and timestamp
"""
@spec header() :: String.t()
def header, do: @header
defp verify(conn, hash, ts, body, secret) do
case Signature.verify(hash, ts, body, secret) do
{:error, message, computed} -> {:error, message, conn, {hash, ts, body}, computed}
ok -> ok
end
end
defp read_body(%{body_params: %Plug.Conn.Unfetched{}} = conn, read_opts) do
read_body(conn, "", Plug.Conn.read_body(conn, read_opts), read_opts)
end
defp read_body(conn, _read_opts), do: {:ok, conn, conn.body_params}
defp read_body(_conn, body, {:ok, more_body, conn}, read_opts) do
{:ok, Plug.Conn.fetch_query_params(conn, read_opts), body <> more_body}
end
defp read_body(_conn, body, {:more, more_body, conn}, read_opts) do
read_body(conn, body <> more_body, Plug.Conn.read_body(conn, read_opts), read_opts)
end
defp read_body(conn, _body, {:error, error}, _read_opts), do: {:error, error, conn}
defp get_signature(conn) do
case Plug.Conn.get_req_header(conn, @header) do
[header] ->
%{"ts" => ts, "v1" => hash} =
Regex.named_captures(~r/^t=(?<ts>\d+)[, ]v1=(?<v1>[^, ]+)$/, String.trim(header))
{:ok, conn, {String.to_integer(ts), String.trim(hash)}}
_ ->
{:error, "Could not find valid Sanity webhook signature header", conn}
end
end
defp put_debug(conn, nil, nil, secret, error) do
secret = secret |> get_secret() |> elem(1)
Plug.Conn.put_private(conn, @plug_key, %__MODULE__{error: error, secret: secret})
end
defp put_debug(conn, {hash, ts, body}, computed, secret, error) do
{:ok, secret} = get_secret(secret)
Plug.Conn.put_private(conn, @plug_key, %__MODULE__{
hash: hash,
error: error,
ts: ts,
computed: computed,
secret: secret,
body: body
})
end
defp handle_error(conn), do: conn |> Plug.Conn.put_status(400) |> Plug.Conn.halt()
defp get_secret({m, f, a}), do: get_secret(apply(m, f, a))
defp get_secret(fun) when is_function(fun), do: get_secret(fun.())
defp get_secret({:ok, secret}) when is_binary(secret), do: {:ok, secret}
defp get_secret({:ok, nil}), do: get_secret(nil)
defp get_secret({:error, _} = error), do: error
defp get_secret(secret) when is_binary(secret), do: {:ok, secret}
defp get_secret(nil) do
case Application.get_env(:sanity_webhook_plug, :webhook_secret) do
nil ->
{:error, "No secret configured for SanityWebhookPlug"}
secret ->
{:ok, secret}
end
end
end