Enable subscribing to specific actions
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
←
|
||||
<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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+2
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user