Enable subscribing to specific actions

This commit is contained in:
Stanko K.R.
2025-09-10 19:09:20 +02:00
parent a515ea3b1f
commit 9cfd1e43af
16 changed files with 288 additions and 15 deletions
+51
View File
@@ -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
+29
View File
@@ -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)
+6 -15
View File
@@ -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
+4
View File
@@ -4,6 +4,10 @@
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<%= link_to webhooks_path, class: "btn" do %>
<%= icon_tag "world" %>
<span class="for-screen-reader">Webhooks</span>
<% end %>
<%= link_to workflows_path, class: "btn" do %>
<%= icon_tag "bolt" %>
<span class="for-screen-reader">Workflows</span>
+8
View File
@@ -0,0 +1,8 @@
<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %>
<div>
<%= delivery.state %>
</div>
<div>
<%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
</div>
<% end %>
+13
View File
@@ -0,0 +1,13 @@
<li class="flex align-center gap-half" data-navigable-list-target="item">
<%= link_to webhook, class: "txt-ink flex gap-half align-center min-width" do %>
<strong class="overflow-ellipsis"><%= webhook.name %></strong>
<% end %>
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true">
<%= 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" %>
<span class="for-screen-reader">Remove <%= webhook.name %></span>
<% end %>
</li>
+25
View File
@@ -0,0 +1,25 @@
<% @page_title = "New Webhook" %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel center" style="--panel-size: 40ch;" style="view-transition-name: <%= dom_id(@webhook) %>">
<%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %>
<h2 class="txt-large margin-none">
<%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
</h2>
<div class="flex flex-column align-start full-width">
<%= form.label :actions %>
<%= render "webhooks/form/actions", form: form %>
</div>
<%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %>
<span>Update Webhook</span>
<% end %>
<%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+16
View File
@@ -0,0 +1,16 @@
<ul>
<%= form.collection_check_boxes \
:subscribed_actions,
Webhook::PERMITTED_ACTIONS,
:itself,
:humanize do |item| %>
<li>
<label>
<span>
<%= item.check_box %>
</span>
<span><%= item.text %></span>
</label>
</li>
<% end %>
</ul>
+32
View File
@@ -0,0 +1,32 @@
<% @page_title = "Webhooks" %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title" style="view-transistion-name: webhooks-title"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<%= link_to new_webhook_path, class: "btn" do %>
<%= icon_tag "add" %>
<span class="for-screen-reader">Create a new webhook</span>
<% end %>
</div>
<% end %>
<section class="webhooks">
<%= 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 %>
<div>
<ul class="flex flex-column gap-half" data-filter-target="list">
<%= render partial: "webhooks/webhook", collection: @page.records %>
</ul>
<footer class="">
Press <kbd class="kbd">↑</kbd><kbd class="kbd">↓</kbd> to move, <kbd class="kbd">enter</kbd> to visit webhook, <kbd class="kbd">SHIFT+ENTER</kbd> to toggle.
</footer>
</div>
<% end %>
</section>
+27
View File
@@ -0,0 +1,27 @@
<% @page_title = "Webhooks" %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title" style="view-transistion-name: webhooks-title"><%= @page_title %></h1>
<% end %>
<article class="panel center" style="--panel-size: 40ch;" style="view-transition-name: <%= dom_id(@webhook) %>">
<%= form_with model: @webhook, url: webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %>
<h2 class="txt-large margin-none">
<%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
</h2>
<%= form.text_field :url, required: true, class: "input", placeholder: "https://example.com", data: { action: "keydown.esc@document->form#cancel" } %>
<div class="flex flex-column align-start full-width">
<%= form.label :actions %>
<%= render "webhooks/form/actions", form: form %>
</div>
<%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %>
<span>Create Webhook</span>
<% end %>
<%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+61
View File
@@ -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 %>
<span class="overflow-ellipsis">
&larr;
<strong class="font-black">Webhooks</strong>
</span>
<% end %>
<% end %>
<div class="panel shadow center txt-align-center margin-block-end-double">
<div class="flex flex-column gap position-relative">
<%= link_to edit_webhook_path(@webhook), class: "user-edit-link btn" do %>
<%= icon_tag "pencil" %>
<span class="for-screen-reader">Edit</span>
<% end %>
<div class="flex flex-column gap-half margin-block-end">
<h1 class="txt-x-large margin-none"><%= @webhook.name %></h1>
</div>
<div>
<%= @webhook.url %>
</div>
<div>
<h2 class="margin-none">Signing secret</h2>
<p>
<b><%= @webhook.signing_secret %></b>
</p>
<p>
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.
</p>
</div>
<div class="flex flex-column gap-half margin-block-end">
<h2 class="margin-none">Subscribed to</h2>
<ul>
<% @webhook.subscribed_actions.each do |action| %>
<li><%= action.humanize %></li>
<% end %>
</ul>
</div>
<div class="flex flex-column gap-half margin-block-end">
<h2 class="margin-none">Deliveries</h2>
<% if @webhook.deliveries.empty? %>
<p>This Webhook hasn't been triggered yet<p/>
<% else %>
<ul>
<%= render partial: "webhooks/delivery", collection: @webhook.deliveries.chronologically.limit(20), as: :delivery %>
</ul>
<% end %>
</div>
</div>
</div>
+2
View File
@@ -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
@@ -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
Generated
+1
View File
@@ -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
+10
View File
@@ -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:
+2
View File
@@ -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 %>