Files
wanderer/lib/wanderer_app/api/map_webhook_subscription.ex
2025-11-23 18:04:30 +00:00

279 lines
6.5 KiB
Elixir

defmodule WandererApp.Api.MapWebhookSubscription do
@moduledoc """
Ash resource for managing webhook subscriptions for map events.
Stores webhook endpoint configurations that receive HTTP POST notifications
when events occur on a specific map.
"""
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshCloak]
postgres do
repo(WandererApp.Repo)
table("map_webhook_subscriptions_v1")
end
cloak do
vault(WandererApp.Vault)
attributes([:secret])
decrypt_by_default([:secret])
end
code_interface do
define(:create, action: :create)
define(:update, action: :update)
define(:destroy, action: :destroy)
define(:by_id,
get_by: [:id],
action: :read
)
define(:by_map, action: :by_map, args: [:map_id])
define(:active_by_map, action: :active_by_map, args: [:map_id])
define(:rotate_secret, action: :rotate_secret)
end
actions do
default_accept [
:map_id,
:url,
:events,
:active?
]
defaults [:read, :destroy]
update :update do
accept [
:url,
:events,
:active?,
:last_delivery_at,
:last_error,
:last_error_at,
:consecutive_failures,
:secret
]
require_atomic? false
end
read :by_map do
argument :map_id, :uuid, allow_nil?: false
filter expr(map_id == ^arg(:map_id))
prepare build(sort: [inserted_at: :desc])
end
read :active_by_map do
argument :map_id, :uuid, allow_nil?: false
filter expr(map_id == ^arg(:map_id) and active? == true)
prepare build(sort: [inserted_at: :desc])
end
create :create do
accept [
:map_id,
:url,
:events,
:active?
]
# Validate webhook URL format
change fn changeset, _context ->
case Ash.Changeset.get_attribute(changeset, :url) do
nil ->
changeset
url ->
case validate_webhook_url_format(url) do
:ok ->
changeset
{:error, message} ->
Ash.Changeset.add_error(changeset, field: :url, message: message)
end
end
end
# Validate events list
change fn changeset, _context ->
case Ash.Changeset.get_attribute(changeset, :events) do
nil ->
changeset
events when is_list(events) ->
case validate_events_list(events) do
:ok ->
changeset
{:error, message} ->
Ash.Changeset.add_error(changeset, field: :events, message: message)
end
_ ->
changeset
end
end
# Generate secret on creation
change fn changeset, _context ->
secret = generate_webhook_secret()
Ash.Changeset.force_change_attribute(changeset, :secret, secret)
end
end
update :rotate_secret do
accept []
require_atomic? false
change fn changeset, _context ->
new_secret = generate_webhook_secret()
Ash.Changeset.change_attribute(changeset, :secret, new_secret)
end
end
end
validations do
validate present(:url), message: "URL is required"
validate present(:events), message: "Events array is required"
validate present(:map_id), message: "Map ID is required"
end
attributes do
uuid_primary_key :id
attribute :map_id, :uuid do
allow_nil? false
end
attribute :url, :string do
allow_nil? false
# 2KB limit as per security requirements
constraints max_length: 2000
end
attribute :events, {:array, :string} do
allow_nil? false
default []
constraints min_length: 1,
# Reasonable limit on number of event types
max_length: 50,
# Max length per event type
items: [max_length: 100]
end
attribute :secret, :string do
allow_nil? false
# Hide in logs and API responses
sensitive? true
end
attribute :active?, :boolean do
allow_nil? false
default true
end
# Delivery tracking fields
attribute :last_delivery_at, :utc_datetime do
allow_nil? true
end
attribute :last_error, :string do
allow_nil? true
constraints max_length: 1000
end
attribute :last_error_at, :utc_datetime do
allow_nil? true
end
attribute :consecutive_failures, :integer do
allow_nil? false
default 0
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end
relationships do
belongs_to :map, WandererApp.Api.Map do
source_attribute :map_id
destination_attribute :id
attribute_writable? true
end
end
identities do
# Allow multiple webhooks per map, but prevent duplicate URLs per map
identity :unique_url_per_map, [:map_id, :url]
end
# Private helper functions
defp generate_webhook_secret do
:crypto.strong_rand_bytes(32) |> Base.encode64()
end
defp validate_webhook_url_format(url) do
uri = URI.parse(url)
cond do
uri.scheme != "https" ->
{:error, "Webhook URL must use HTTPS"}
uri.host == nil ->
{:error, "Webhook URL must have a valid host"}
uri.host in ["localhost", "127.0.0.1", "0.0.0.0"] ->
{:error, "Webhook URL cannot use localhost or loopback addresses"}
String.starts_with?(uri.host, "192.168.") or String.starts_with?(uri.host, "10.") or
is_private_ip_172_range?(uri.host) ->
{:error, "Webhook URL cannot use private network addresses"}
byte_size(url) > 2000 ->
{:error, "Webhook URL cannot exceed 2000 characters"}
true ->
:ok
end
end
defp validate_events_list(events) do
alias WandererApp.ExternalEvents.Event
# Get valid event types as strings
valid_event_strings =
Event.supported_event_types()
|> Enum.map(&Atom.to_string/1)
# Add wildcard as valid option
valid_events = ["*" | valid_event_strings]
invalid_events = Enum.reject(events, fn event -> event in valid_events end)
if Enum.empty?(invalid_events) do
:ok
else
{:error, "Invalid event types: #{Enum.join(invalid_events, ", ")}"}
end
end
# Check if IP is in the 172.16.0.0/12 range (172.16.0.0 to 172.31.255.255)
defp is_private_ip_172_range?(host) do
case :inet.parse_address(String.to_charlist(host)) do
{:ok, {172, b, _, _}} when b >= 16 and b <= 31 ->
true
_ ->
false
end
end
end