diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 000000000..65299121b --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,51 @@ +class WebhooksController < ApplicationController + include FilterScoped + + before_action :set_webhook, except: %i[ index new create ] + + def index + set_page_and_extract_portion_from Webhook.all.ordered + end + + def show + end + + def new + @webhook = Webhook.new + end + + def create + @webhook = Webhook.new(webhook_params) + + if @webhook.save + redirect_to @webhook, status: :see_other + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @webhook.update(webhook_params.except(:url)) + redirect_to @webhook, status: :see_other + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @webhook.destroy! + redirect_to webhooks_path, status: :see_other + end + + private + def set_webhook + @webhook = Webhook.find(params[:id]) + end + + def webhook_params + params.require(:webhook).permit(:name, :url, subscribed_actions: []) + end +end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 6878b62b3..50bee846f 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -1,17 +1,46 @@ class Webhook < ApplicationRecord PERMITTED_SCHEMES = %w[ http https ].freeze + PERMITTED_ACTIONS = %w[ + card_assigned + card_boosted + card_closed + card_collection_changed + card_created + card_popped + card_published + card_reopened + card_staged + card_title_changed + card_unassigned + card_unstaged + comment_created + ].freeze encrypts :signing_secret has_secure_token :signing_secret has_many :deliveries, dependent: :delete_all + serialize :subscribed_actions, type: Array, coder: JSON + + scope :ordered, -> { order(name: :asc, id: :desc) } + scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } + + # normalizes :subscribed_actions, with: ->(value) { Array(value).map(&:to_s).map(&:strip).reject(&:blank?).uniq & PERMITTED_ACTIONS } + + validates :name, presence: true + validates :subscribed_actions, presence: true + validate :validate_url def deactivate update_columns active: false end + def trigger(event) + deliveries.create!(event: event).deliver_later + end + private def validate_url uri = URI.parse(url.presence) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 5e3252dea..b5baa51fd 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -3,29 +3,22 @@ class Webhook::Delivery < ApplicationRecord ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 PRIVATE_IP_RANGES = [ - # Loopback - IPAddr.new("127.0.0.0/8"), - IPAddr.new("::1/128"), # IPv4 mapped to IPv6 IPAddr.new("::ffff:0:0/96"), - # RFC1918 - local network IP addresses - IPAddr.new("10.0.0.0/8"), - IPAddr.new("172.16.0.0/12"), - IPAddr.new("192.168.0.0/16"), # Link-local (DHCP and router stuff) IPAddr.new("169.254.0.0/16"), - IPAddr.new("fe80::/10"), - # ULA - IPAddr.new("fc00::/7") + IPAddr.new("fe80::/10") ].freeze belongs_to :webhook belongs_to :event + encrypts :request, :response + store :request, coder: JSON store :response, coder: JSON - encrypts :request, :response + scope :chronologically, -> { order created_at: :asc, id: :desc } enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending @@ -80,10 +73,8 @@ class Webhook::Delivery < ApplicationRecord end end - ip_addresses.any? do |ip_address| - PRIVATE_IP_RANGES.any? do |private_ip_range| - private_ip_range.include?(ip_address) - end + ip_addresses.any? do |ip| + ip.private? || ip.loopback? || PRIVATE_IP_RANGES.any? { |range| range.include?(ip) } end end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 6d41d439d..6ab7e3bb7 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -4,6 +4,10 @@ <%= render "filters/menu", user_filtering: @user_filtering %>

<%= @page_title %>

+ <%= link_to webhooks_path, class: "btn" do %> + <%= icon_tag "world" %> + Webhooks + <% end %> <%= link_to workflows_path, class: "btn" do %> <%= icon_tag "bolt" %> Workflows diff --git a/app/views/webhooks/_delivery.html.erb b/app/views/webhooks/_delivery.html.erb new file mode 100644 index 000000000..ced1f9e2c --- /dev/null +++ b/app/views/webhooks/_delivery.html.erb @@ -0,0 +1,8 @@ +<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %> +
+ <%= delivery.state %> +
+
+ <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %> +
+<% end %> diff --git a/app/views/webhooks/_webhook.html.erb b/app/views/webhooks/_webhook.html.erb new file mode 100644 index 000000000..7f77b9a34 --- /dev/null +++ b/app/views/webhooks/_webhook.html.erb @@ -0,0 +1,13 @@ +
  • + <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width" do %> + <%= webhook.name %> + <% end %> + + + + <%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative", + data: { turbo_confirm: "Are you sure you want to permanently remove this webhook?" } do %> + <%= icon_tag "minus" %> + Remove <%= webhook.name %> + <% end %> +
  • diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb new file mode 100644 index 000000000..1c2513a1e --- /dev/null +++ b/app/views/webhooks/edit.html.erb @@ -0,0 +1,25 @@ +<% @page_title = "New Webhook" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    +<% end %> + +
    + <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +

    + <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> +

    + +
    + <%= form.label :actions %> + <%= render "webhooks/form/actions", form: form %> +
    + + <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> + Update Webhook + <% end %> + + <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
    diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb new file mode 100644 index 000000000..bd592ca20 --- /dev/null +++ b/app/views/webhooks/form/_actions.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb new file mode 100644 index 000000000..1b835634f --- /dev/null +++ b/app/views/webhooks/index.html.erb @@ -0,0 +1,32 @@ +<% @page_title = "Webhooks" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    + +
    + <%= link_to new_webhook_path, class: "btn" do %> + <%= icon_tag "add" %> + Create a new webhook + <% end %> +
    +<% end %> + +
    + <%= tag.div class: "panel shadow center", data: { + controller: "navigable-list", + action: "keydown->navigable-list#navigate", + navigable_list_focus_on_selection_value: true, + navigable_list_actionable_items_value: true + } do %> +
    +
      + <%= render partial: "webhooks/webhook", collection: @page.records %> +
    + +
    + Press to move, enter to visit webhook, SHIFT+ENTER to toggle. +
    +
    + <% end %> +
    diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb new file mode 100644 index 000000000..996e2e3ea --- /dev/null +++ b/app/views/webhooks/new.html.erb @@ -0,0 +1,27 @@ +<% @page_title = "Webhooks" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    +<% end %> + +
    + <%= form_with model: @webhook, url: webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +

    + <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> +

    + + <%= form.text_field :url, required: true, class: "input", placeholder: "https://example.com", data: { action: "keydown.esc@document->form#cancel" } %> + +
    + <%= form.label :actions %> + <%= render "webhooks/form/actions", form: form %> +
    + + <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> + Create Webhook + <% end %> + + <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
    diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb new file mode 100644 index 000000000..15b4fd117 --- /dev/null +++ b/app/views/webhooks/show.html.erb @@ -0,0 +1,61 @@ +<% @page_title = @webhook.name %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> + + <%= link_to webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + Webhooks + + <% end %> +<% end %> + +
    +
    + <%= link_to edit_webhook_path(@webhook), class: "user-edit-link btn" do %> + <%= icon_tag "pencil" %> + Edit + <% end %> + +
    +

    <%= @webhook.name %>

    +
    + +
    + <%= @webhook.url %> +
    + +
    +

    Signing secret

    +

    + <%= @webhook.signing_secret %> +

    +

    + We'll sent a `X-Webhook-Signature` header with each request. + You can generate a HMAC using SHA256 of the request body with this secret + to verify that the request came from us. +

    +
    + +
    +

    Subscribed to

    +
      + <% @webhook.subscribed_actions.each do |action| %> +
    • <%= action.humanize %>
    • + <% end %> +
    +
    + +
    +

    Deliveries

    + <% if @webhook.deliveries.empty? %> +

    This Webhook hasn't been triggered yet

    + <% else %> +

      + <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.chronologically.limit(20), as: :delivery %> +
    + <% end %> +
    +
    +
    diff --git a/config/routes.rb b/config/routes.rb index c60c7b22c..020b7e51c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,8 @@ Rails.application.routes.draw do resources :stages, module: :workflows end + resources :webhooks + resources :uploads, only: :create get "/u/*slug" => "uploads#show", as: :upload diff --git a/db/migrate/20250909113219_create_webhooks.rb b/db/migrate/20250909113219_create_webhooks.rb index 9aca01aa0..29d952552 100644 --- a/db/migrate/20250909113219_create_webhooks.rb +++ b/db/migrate/20250909113219_create_webhooks.rb @@ -4,6 +4,7 @@ class CreateWebhooks < ActiveRecord::Migration[8.1] t.boolean :active, default: true, null: false t.string :name t.text :url, null: false + t.text :subscribed_actions, index: true t.string :signing_secret, null: false t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 5303449d5..186a9a889 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -472,6 +472,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do t.datetime "created_at", null: false t.string "name" t.string "signing_secret", null: false + t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false end diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 36d94dfac..101db8857 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1396,6 +1396,16 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: subscribed_actions + cast_type: *15 + sql_type_metadata: *16 + 'null': true + default: + default_function: + collation: + comment: - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index c2d308c0a..eaf09613a 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -3,9 +3,11 @@ active: name: Production API url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b + subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %> inactive: active: false name: Test Webhook url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m + subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %>