mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-01 13:33:02 +00:00
279 lines
6.5 KiB
Elixir
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
|