All 4 webhook controllers now verify the signature, insert an Oban job, and return immediately. Previously, Stripe and PagerDuty did DB queries + writes inline; Gaiia ran upserts; Agent Release broadcast to all connected WebSockets — all in the request path. New workers: - StripeWebhookWorker — delegates to WebhookProcessor.process/1 - GaiiaWebhookWorker — delegates to Webhooks.process_event/3 - PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key - AgentReleaseWebhookWorker — broadcast mass update to agents Also fix: log error when resolve_alert fails in agent_channel (was silently discarded, causing "Resolving stuck device_down alert" to repeat in logs).
98 lines
2.7 KiB
Elixir
98 lines
2.7 KiB
Elixir
defmodule ToweropsWeb.Api.V1.StripeWebhookController do
|
|
use ToweropsWeb, :controller
|
|
|
|
alias Towerops.Workers.StripeWebhookWorker
|
|
|
|
require Logger
|
|
|
|
def create(conn, _params) do
|
|
signature = conn |> get_req_header("stripe-signature") |> List.first()
|
|
raw_body = conn.private[:raw_body] || ""
|
|
|
|
with :ok <- verify_signature(raw_body, signature),
|
|
{:ok, event} <- decode_payload(raw_body) do
|
|
case Oban.insert(StripeWebhookWorker.new(%{event: event})) do
|
|
{:ok, _job} ->
|
|
json(conn, %{received: true})
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to enqueue Stripe webhook: #{inspect(changeset.errors)}")
|
|
conn |> put_status(500) |> json(%{error: "Failed to enqueue"})
|
|
end
|
|
else
|
|
{:error, :invalid_signature} ->
|
|
conn |> put_status(401) |> json(%{error: "Invalid signature"})
|
|
|
|
{:error, :invalid_json} ->
|
|
conn |> put_status(400) |> json(%{error: "Invalid JSON payload"})
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Stripe webhook verification failed: #{inspect(reason)}")
|
|
conn |> put_status(400) |> json(%{error: "Verification failed"})
|
|
end
|
|
end
|
|
|
|
defp decode_payload(raw_body) do
|
|
case Jason.decode(raw_body) do
|
|
{:ok, event} -> {:ok, event}
|
|
{:error, _} -> {:error, :invalid_json}
|
|
end
|
|
end
|
|
|
|
defp verify_signature(payload, signature_header) do
|
|
webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret)
|
|
|
|
with {:ok, timestamp, signature} <- parse_signature_header(signature_header),
|
|
:ok <- check_timestamp_tolerance(timestamp, 300) do
|
|
signed_payload = "#{timestamp}.#{payload}"
|
|
|
|
expected_signature =
|
|
:hmac
|
|
|> :crypto.mac(:sha256, webhook_secret, signed_payload)
|
|
|> Base.encode16(case: :lower)
|
|
|
|
if Plug.Crypto.secure_compare(signature, expected_signature) do
|
|
:ok
|
|
else
|
|
{:error, :invalid_signature}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp parse_signature_header(header) do
|
|
parts = String.split(header, ",")
|
|
|
|
timestamp =
|
|
Enum.find_value(parts, fn part ->
|
|
case String.split(part, "=") do
|
|
["t", t] -> t
|
|
_ -> nil
|
|
end
|
|
end)
|
|
|
|
signature =
|
|
Enum.find_value(parts, fn part ->
|
|
case String.split(part, "=") do
|
|
["v1", sig] -> sig
|
|
_ -> nil
|
|
end
|
|
end)
|
|
|
|
if timestamp && signature do
|
|
{:ok, timestamp, signature}
|
|
else
|
|
{:error, :malformed_header}
|
|
end
|
|
end
|
|
|
|
defp check_timestamp_tolerance(timestamp_str, max_age_seconds) do
|
|
timestamp = String.to_integer(timestamp_str)
|
|
now = System.system_time(:second)
|
|
|
|
if abs(now - timestamp) <= max_age_seconds do
|
|
:ok
|
|
else
|
|
{:error, :timestamp_too_old}
|
|
end
|
|
end
|
|
end
|