Merge pull request #1083 from basecamp/webhooks

Webhooks
This commit is contained in:
Stanko Krtalić
2025-09-16 20:25:26 +02:00
committed by GitHub
59 changed files with 1610 additions and 43 deletions
+1
View File
@@ -193,6 +193,7 @@
/* Lists */
:where(.list-style-none) {
list-style: none;
margin: 0 auto;
padding: 0;
}
+1 -1
View File
@@ -1,3 +1,3 @@
class AdminController < ApplicationController
include StaffOnly
before_action :ensure_is_staff_member
end
+1 -1
View File
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity
include Authentication, Authorization, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity
stale_when_importmap_changes
allow_browser versions: :modern, block: -> { render "errors/not_acceptable", layout: "error" }
+10
View File
@@ -0,0 +1,10 @@
module Authorization
private
def ensure_can_administer
head :forbidden unless Current.user.admin?
end
def ensure_is_staff_member
head :forbidden unless Current.user.staff?
end
end
-12
View File
@@ -1,12 +0,0 @@
module StaffOnly
extend ActiveSupport::Concern
included do
before_action :ensure_staff
end
private
def ensure_staff
head :forbidden unless Current.user.staff?
end
end
@@ -1,6 +1,5 @@
class Conversations::MessagesController < ApplicationController
include StaffOnly
before_action :ensure_is_staff_member
before_action :set_conversation
def index
+1 -1
View File
@@ -1,5 +1,5 @@
class ConversationsController < ApplicationController
include StaffOnly
before_action :ensure_is_staff_member
def create
Current.user.start_or_continue_conversation
@@ -0,0 +1,14 @@
class Webhooks::ActivationsController < ApplicationController
before_action :ensure_can_administer
before_action :set_webhook
def create
@webhook.activate unless @webhook.active?
redirect_to @webhook, status: :see_other
end
private
def set_webhook
@webhook = Webhook.find(params[:webhook_id])
end
end
+51
View File
@@ -0,0 +1,51 @@
class WebhooksController < ApplicationController
include FilterScoped
before_action :ensure_can_administer
before_action :set_collection
before_action :set_webhook, except: %i[ index new create ]
def index
set_page_and_extract_portion_from @collection.webhooks.ordered
end
def show
end
def new
@webhook = @collection.webhooks.new
end
def create
@webhook = @collection.webhooks.create!(webhook_params)
redirect_to @webhook
end
def edit
end
def update
@webhook.update!(webhook_params.except(:url))
redirect_to @webhook
end
def destroy
@webhook.destroy!
redirect_to collection_webhooks_path, status: :see_other
end
private
def set_collection
@collection = Collection.find(params[:collection_id])
end
def set_webhook
@webhook = @collection.webhooks.find(params[:id])
end
def webhook_params
params
.expect(webhook: [ :name, :url, subscribed_actions: [] ])
.merge(collection_id: @collection.id)
end
end
+16
View File
@@ -0,0 +1,16 @@
require "active_job/continuable"
class Event::WebhookDispatchJob < ApplicationJob
include ActiveJob::Continuable
queue_as :webhooks
def perform(event)
step :dispatch do |step|
Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|
webhook.trigger(event)
step.advance! from: webhook.id
end
end
end
end
@@ -0,0 +1,7 @@
class Webhook::CleanupDeliveriesJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do
Webhook::Delivery.cleanup
end
end
end
+7
View File
@@ -0,0 +1,7 @@
class Webhook::DeliveryJob < ApplicationJob
queue_as :webhooks
def perform(delivery)
delivery.deliver
end
end
+1
View File
@@ -8,6 +8,7 @@ class Collection < ApplicationRecord
has_many :cards, dependent: :destroy
has_many :tags, -> { distinct }, through: :cards
has_many :events
has_many :webhooks, dependent: :destroy
has_one :entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
scope :alphabetically, -> { order("lower(name)") }
+8
View File
@@ -5,9 +5,12 @@ class Event < ApplicationRecord
belongs_to :creator, class_name: "User"
belongs_to :eventable, polymorphic: true
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
scope :chronologically, -> { order created_at: :asc, id: :desc }
after_create -> { eventable.event_was_created(self) }
after_create_commit :dispatch_webhooks
delegate :card, to: :eventable
@@ -18,4 +21,9 @@ class Event < ApplicationRecord
def notifiable_target
eventable
end
private
def dispatch_webhooks
Event::WebhookDispatchJob.perform_later(self)
end
end
+1 -1
View File
@@ -18,7 +18,7 @@ module User::Role
admin? || other == self
end
def can_administer?(other)
def can_administer?(other = nil)
admin? && other != self
end
end
+78
View File
@@ -0,0 +1,78 @@
class Webhook < ApplicationRecord
include Triggerable
SLACK_WEBHOOK_URL_REGEX = %r{//hooks\.slack\.com/services/T[^\/]+/B[^\/]+/[^\/]+\Z}i
CAMPFIRE_WEBHOOK_URL_REGEX = %r{/rooms/\d+/\d+-[^\/]+/messages\Z}i
BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX = %r{/\d+/integrations/[^\/]+/buckets/\d+/chats/\d+/lines\Z}i
PERMITTED_SCHEMES = %w[ http https ].freeze
PERMITTED_ACTIONS = %w[
card_assigned
card_closed
card_collection_changed
card_due_date_added
card_due_date_changed
card_due_date_removed
card_published
card_reopened
card_staged
card_title_changed
card_unassigned
card_unstaged
comment_created
].freeze
has_secure_token :signing_secret
has_many :deliveries, dependent: :delete_all
has_one :delinquency_tracker, dependent: :delete
belongs_to :collection
serialize :subscribed_actions, type: Array, coder: JSON
scope :ordered, -> { order(name: :asc, id: :desc) }
scope :active, -> { where(active: true) }
after_create :create_delinquency_tracker!
normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
validates :name, presence: true
validate :validate_url
def activate
update_columns active: true
end
def deactivate
update_columns active: false
end
def renderer
@renderer ||= ApplicationController.renderer.new
end
def for_basecamp?
url.match? BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX
end
def for_campfire?
url.match? CAMPFIRE_WEBHOOK_URL_REGEX
end
def for_slack?
url.match? SLACK_WEBHOOK_URL_REGEX
end
private
def validate_url
uri = URI.parse(url.presence)
if PERMITTED_SCHEMES.exclude?(uri.scheme)
errors.add :url, "must use #{PERMITTED_SCHEMES.to_choice_sentence}"
end
rescue URI::InvalidURIError
errors.add :url, "not a URL"
end
end
+42
View File
@@ -0,0 +1,42 @@
class Webhook::DelinquencyTracker < ApplicationRecord
DELINQUENCY_THRESHOLD = 10
DELINQUENCY_DURATION = 1.hour
belongs_to :webhook
def record_delivery_of(delivery)
if delivery.succeeded?
reset
else
mark_first_failure_time if consecutive_failures_count.zero?
increment!(:consecutive_failures_count)
webhook.deactivate if delinquent?
end
end
private
def reset
update_columns consecutive_failures_count: 0, first_failure_at: nil
end
def mark_first_failure_time
update_columns first_failure_at: Time.current
end
def delinquent?
failing_for_too_long? && too_many_consecutive_failures?
end
def failing_for_too_long?
if first_failure_at
first_failure_at.before?(DELINQUENCY_DURATION.ago)
else
false
end
end
def too_many_consecutive_failures?
consecutive_failures_count >= DELINQUENCY_THRESHOLD
end
end
+150
View File
@@ -0,0 +1,150 @@
class Webhook::Delivery < ApplicationRecord
STALE_TRESHOLD = 7.days
USER_AGENT = "fizzy/1.0.0 Webhook"
ENDPOINT_TIMEOUT = 7.seconds
DNS_RESOLUTION_TIMEOUT = 2
DISALLOWED_IP_RANGES = [
# IPv4 mapped to IPv6
IPAddr.new("::ffff:0:0/96"),
# Broadcasts
IPAddr.new("0.0.0.0/8")
].freeze
belongs_to :webhook
belongs_to :event
store :request, coder: JSON
store :response, coder: JSON
enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending
scope :ordered, -> { order created_at: :desc, id: :desc }
scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }
after_create_commit :deliver_later
def self.cleanup
stale.delete_all
end
def deliver_later
Webhook::DeliveryJob.perform_later(self)
end
def deliver
in_progress!
self.request[:headers] = headers
self.response = perform_request
self.state = :completed
save!
webhook.delinquency_tracker.record_delivery_of(self)
rescue
errored!
raise
end
def failed?
(errored? || completed?) && !succeeded?
end
def succeeded?
completed? && response[:error].blank? && response[:code].between?(200, 299)
end
private
def perform_request
if private_uri?
{ error: :private_uri }
else
response = http.request(
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
)
{ code: response.code.to_i }
end
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
{ error: :dns_lookup_failed }
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
{ error: :connection_timeout }
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
{ error: :destination_unreachable }
rescue OpenSSL::SSL::SSLError
{ error: :failed_tls }
end
def private_uri?
ip_addresses = []
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
dns.each_address(uri.host) do |ip_address|
ip_addresses << IPAddr.new(ip_address)
end
end
ip_addresses.any? do |ip|
ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
end
end
def uri
@uri ||= URI(webhook.url)
end
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
http.open_timeout = ENDPOINT_TIMEOUT
http.read_timeout = ENDPOINT_TIMEOUT
end
end
def headers
{
"User-Agent" => USER_AGENT,
"Content-Type" => "application/json",
"X-Webhook-Signature" => signature,
"X-Webhook-Timestamp" => event.created_at.utc.iso8601
}
end
def signature
OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
end
def payload
@payload ||= if webhook.for_basecamp?
{ line: { content: render_payload(formats: :html) } }.to_json
elsif webhook.for_campfire?
render_payload(formats: :html)
elsif webhook.for_slack?
html = render_payload(formats: :html)
{ text: convert_html_to_mrkdwn(html) }.to_json
else
render_payload(formats: :json)
end
end
def render_payload(**options)
webhook.renderer.render(layout: false, template: "webhooks/event", assigns: { event: event }, **options).strip
end
def convert_html_to_mrkdwn(html)
document = Nokogiri::HTML5(html)
document.css("a").each do |a|
a.replace("<#{a["href"].strip}|#{a.text}>") if a["href"].present?
end
document.css("b").each do |b|
b.replace("*#{b.text}*")
end
document.css("i").each do |i|
i.replace("_#{i.text}_")
end
document.text
end
end
+12
View File
@@ -0,0 +1,12 @@
module Webhook::Triggerable
extend ActiveSupport::Concern
included do
scope :triggered_by, ->(event) { where(collection: event.collection).triggered_by_action(event.action) }
scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") }
end
def trigger(event)
deliveries.create!(event: event)
end
end
+26
View File
@@ -0,0 +1,26 @@
json.cache! card do
json.(card, :id, :title, :status)
json.image_url card.image.presence && url_for(card.image)
json.golden card.golden?
json.last_active_at card.last_active_at.utc
json.created_at card.created_at.utc
json.url card_url(card)
json.collection do
json.partial! "collections/collection", locals: { collection: card.collection }
end
json.stage do
if card.stage
json.partial! "workflows/stages/stage", stage: card.stage
else
nil
end
end
json.creator do
json.partial! "users/user", user: card.creator
end
end
+4
View File
@@ -0,0 +1,4 @@
<%= link_to collection_webhooks_path(collection_id: collection), class: ["btn tooltip", {"btn--reversed": collection.webhooks.any?}] do %>
<%= icon_tag "world" %>
<span class="for-screen-reader">Webhooks</span>
<% end %>
@@ -0,0 +1,18 @@
json.cache! comment do
json.(comment, :id)
json.created_at comment.created_at.utc
json.updated_at comment.updated_at.utc
json.body do
json.plain_text comment.body.to_plain_text
json.html comment.body.to_s
end
json.creator do
json.partial! "users/user", user: comment.creator
end
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
json.url card_comment_url(comment.card_id, comment.id)
end
+1
View File
@@ -20,6 +20,7 @@
<div class="header__actions header__actions--end">
<% if collection = @filter.single_collection %>
<%= render "cards/webhooks", collection: collection if Current.user.admin? %>
<%= render "cards/collection_settings", collection: collection %>
<% end %>
</div>
@@ -0,0 +1,16 @@
json.cache! collection do
json.(collection, :id, :name, :all_access)
json.created_at collection.created_at.utc
json.creator do
json.partial! "users/user", user: collection.creator
end
json.workflow do
if collection.workflow
json.partial! "workflows/workflow", workflow: collection.workflow
else
nil
end
end
end
+7
View File
@@ -0,0 +1,7 @@
json.cache! user do
json.(user, :id, :name, :email_address, :role, :active)
json.created_at user.created_at.utc
json.url user_url(user)
end
+8
View File
@@ -0,0 +1,8 @@
<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %>
<strong>
<%= delivery.state %>
</strong>
<div class="txt-subtle">
<%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
</div>
<% end %>
+13
View File
@@ -0,0 +1,13 @@
<li class="list-style-none margin-none flex align-center gap full-width">
<%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" 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 txt-xx-small flex-item-no-shrink",
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>
+35
View File
@@ -0,0 +1,35 @@
<% @page_title = @webhook.name %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<%= link_to @webhook, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong><%= @page_title %></strong>
</span>
<% end %>
</div>
<% end %>
<article class="panel center txt-align-start" style="view-transition-name: <%= dom_id(@webhook) %>">
<%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<h2 class="txt-large margin-none">
<%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
</h2>
<div class="flex flex-column gap-half">
<%= form.label :actions do %>
<strong>Events</strong><br>
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when these things occur:</p>
<% end %>
<%= render "webhooks/form/actions", form: form %>
</div>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
<span>Save Changes</span>
<% end %>
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+4
View File
@@ -0,0 +1,4 @@
<%= event_action_sentence(@event) %>
<% if @event.eventable %>
<%= link_to "See more", @event.eventable %>
<% end %>
+19
View File
@@ -0,0 +1,19 @@
json.cache! @event do
json.(@event, :id, :action)
json.created_at @event.created_at.utc
json.eventable do
case @event.eventable
when Card then json.partial! "cards/card", card: @event.eventable
when Comment then json.partial! "cards/comments/comment", comment: @event.eventable
end
end
json.collection do
json.partial! "collections/collection", locals: { collection: @event.collection }
end
json.creator do
json.partial! "users/user", user: @event.creator
end
end
+16
View File
@@ -0,0 +1,16 @@
<ul class="flex flex-column align-start gap-half margin-none unpad">
<%= form.collection_check_boxes \
:subscribed_actions,
Webhook::PERMITTED_ACTIONS,
:itself,
:humanize do |item| %>
<li class="list-style-none margin-none flex align-center gap">
<label class="switch toggler__visible-when-off flex-item-no-shrink txt-x-small">
<%= item.check_box(class: "switch__input") %>
<span class="switch__btn round"></span>
<span class="for-screen-reader"><%= item.text %></span>
</label>
<span><%= item.text %></span>
</li>
<% end %>
</ul>
+37
View File
@@ -0,0 +1,37 @@
<% @page_title = "Webhooks" %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<%= link_to cards_path(collection_ids: [ @collection ]), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong><%= @collection.name %></strong>
</span>
<% end %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<%= link_to new_collection_webhook_path, class: "btn" do %>
<%= icon_tag "add" %>
<span class="for-screen-reader">Create a new webhook</span>
<% end %>
</div>
<% end %>
<%= tag.section class: "panel shadow center webhooks" do %>
<% if @page.records.any? %>
<ul class="flex flex-column align-start gap margin-none unpad webhooks-list">
<%= render partial: "webhooks/webhook", collection: @page.records %>
</ul>
<% else %>
<p>Webhooks can notify another application when something happens in this Fizzy collection. You'll choose which events to subscribe to and provide a URL to receive the data.</p>
<p>For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.</p>
<%= link_to new_collection_webhook_path, class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Set up a webhook</span>
<% end %>
<% end %>
<% end %>
+51
View File
@@ -0,0 +1,51 @@
<% @page_title = "Set up a Webhook" %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong>Webhooks</strong>
</span>
<% end %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel center txt-align-start" style="view-transition-name: <%= dom_id(@webhook) %>">
<%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<h2 class="txt-large margin-none">
<%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name this Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
</h2>
<div class="flex flex-column gap-half">
<%= form.label :url do %>
<strong>Payload URL</strong><br>
<p class="margin-none txt-x-small txt-subtle">This is the URL for the app that will receive payloads from Fizzy.</p>
<% end %>
<%= form.url_field :url,
required: true,
pattern: "https?://.*",
title: "Must be an http:// or https:// URL",
class: "input",
placeholder: "https://example.com",
data: { action: "keydown.esc@document->form#cancel" } %>
</div>
<div class="flex flex-column gap-half">
<%= form.label :actions do %>
<strong>Events</strong><br>
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when these things occur:</p>
<% end %>
<%= render "webhooks/form/actions", form: form %>
</div>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
<span>Create Webhook</span>
<% end %>
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+66
View File
@@ -0,0 +1,66 @@
<% @page_title = @webhook.name %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong>Webhooks</strong>
</span>
<% end %>
</div>
<div class="header__actions header__actions--end">
<%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "btn" do %>
<%= icon_tag "pencil" %>
<span class="for-screen-reader">Edit</span>
<% end %>
<div>
<% end %>
<div class="panel shadow center flex flex-column gap txt-align-start">
<header>
<h1 class="txt-x-large margin-none"><%= @webhook.name %></h1>
<span class="txt-medium"><%= @webhook.url %></span>
</header>
<% unless @webhook.active? %>
<div>
<%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
</div>
<% end %>
<div>
<h2 class="margin-none txt-uppercase txt-medium">Secret</h2>
<strong><code><%= @webhook.signing_secret %></code></strong>
<p class="txt-small txt-subtle margin-none">
We'll send a <code>X-Webhook-Signature</code> header with each request.
You can generate a <code>HMAC</code> using <code>SHA256</code> of the request body with this secret
to verify that the request came from us.
</p>
</div>
<div class="flex flex-column">
<h2 class="margin-none txt-uppercase txt-medium">Subscribed to</h2>
<% if @webhook.subscribed_actions.empty? %>
<p class="margin-none txt-subtle">This Webhook isn't subscribed to any events. It will never trigger.<p/>
<% else %>
<ul class="margin-none unpad-block">
<% @webhook.subscribed_actions.each do |action| %>
<li><%= action.humanize %></li>
<% end %>
</ul>
<% end %>
</div>
<div class="flex flex-column">
<h2 class="margin-none txt-uppercase txt-medium">Deliveries</h2>
<% if @webhook.deliveries.empty? %>
<p class="margin-none txt-subtle">This Webhook hasn't been triggered yet<p/>
<% else %>
<ul class="margin-none unpad-block">
<%= render partial: "webhooks/delivery", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>
</ul>
<% end %>
</div>
</div>
@@ -0,0 +1,6 @@
json.cache! workflow do
json.(workflow, :id, :name)
json.created_at workflow.created_at.utc
json.updated_at workflow.updated_at.utc
end
@@ -0,0 +1,9 @@
json.cache! stage do
json.(stage, :id, :name, :color)
json.created_at stage.created_at.utc
json.workflow do
json.partial! "workflows/workflow", workflow: stage.workflow
end
end
+1 -1
View File
@@ -1 +1 @@
hTAo532D++IfAePtvgvwjKd6Cx3RRCdoO+77b4yFvgZSGtEXB6mfNGKlEICRL4o0qh0lqimE2/3Sbmnx97lzhfdYXE3b09KewXIub14s1jzTRWzZSRPNcxPeYou10kiN0b/4R/IFON8+/P7Ratok1MHIrAt4M3EUYwtEpzsi3aOYDGlRoLW5rAxFAHRl7zhgkMH3Kzkmjh2tr+QOc5fxqmYT8ETmLJrOtvJtULMW4mfie722zmwm9RFJxtHpz4gnMAwSr2vaZ0edk0sHeErnDJo7Zl1Ul7uCJyXPP4PCqmuNVOQbxjLMNVTmo8ntUWZa7pvocpa5uYy8DIy3iCAYYIxdh/xhJPnqPC0XQnS6Aj4zaOD/fOXSnEuSQjR8d5XyIUAWuywh6Li7qWyj3W8btfmc1HWOo4cwx8LkwRuUG1r/SvMQdIR1E2xtgROaoXZmPOXmYlFJm3mzy+oGOk7Pa/u/1iiBiNEkDx4nhHe+0XuPZcBe4c9PKn3zw/3SOm32SpBqe6zPZI/68VoiXx5wVaogY5gnKQOHNkhi9RhN6SSrM58oS+EKYkCJ4+iRqj80IQNtj4in4pyQ/ONZgc0tjMh1+C342NkhbaJYmGGUPpPVhZPCUf3clbBXqex15+6UkmymWtz3WH6GixPVwrvp1gGUqOmqCiWVNJM/kQ84IOTzlxfeyKjGK5xKhEC3ajYqXDDUDek/tUIhqtOYxBS9T44cKDPDTL1BKFMdIDR7gdtRyQouW3RvD4juqyjh710I9aq3JB1Jvj8UZ7uSHYEJ9NpmllfAx0RrrxMFHcwr3LqjDlDf7mtACfDISVtD6TQLuGySDIhs8gWTiSuZ75jRTswx8y3paI17Yy0en23lDIQ9hlPh6xvvJWQQtCT6B8GCz56GrLOVDfKM/0qeqJtyed2p2/MIJfy+QmqTSMSeQz+wiLi14I2Vgy229A1CW4izMSoR+RqoZzIzCbZg29croJv9DRhp/nVtkjj/enHnHksZKqoxzf0c7nHSWtGSYtCiewDydsBCioKdo9tn/FM9UKpnRAy1XHiq1DvBQ+wuq74KLnC/4nBZ7P90xlI8jQuQfldpDRqKaIYjIvz8UbLdKOjETT1dJjZ9Tk629zdLEVybLSGhoE0bs460oFvlrjJDCXtgi/3tWbTIuwsxY6Iy3eMiS5Kxl42dnlOdVrxwwY/om/MzL4+yvsx38wXS2QG8lDKz2JuGBS4GYq5EEg==--P2KPkoocz8wNhqSN--1u5WuEBtEaqJckCflHkb6g==
SlqumuYcQYbvuwMZNdQf54cXZ4zBavRLDyl6eiWzEYe/nGlGOlzAYpsPs3ZUA4bJC9tVm8c29E0FzmmG5X6b18ShCB8y6qMiSMEU8CeyMCIlvi0+CeWQPMbujA8ro5P6eE8deZ4+nN1pEqCpK8XCx+HFypx5sLpZ9G2uAPOBNPtOqX9KGXvWfe6RyHvR6+mk+a0jpPs50y2sQvA+/l77h6VnT/uoaa7P6q0YkZWmCWwsPT5ikgP5w+Ur+yn/VfWWS70CIqVSZhZJrzq96OV6GxKLLFdE/2im83Gg2vKODbu10FiQAdjwyubietv4PHLagtXux/mGRtBAzvwt2mKJpOxZFbUYr3u4luplTAxwMMvGMoEvxXs8ujTHjcWL8BCd3TjjOvuB82+/hYI/ds28GwMxDmurKlP0TfQXEUmrx27+mF5Bi/BNyaGwD3EMpP5f71HWxTgdTI2SVbM1pVHS3wnnnhvI4r9TcApt4oasZ2HcUJ1tqYvxDGShFnlsjB0XfJYQg5IM7/6PBr2FMi+7czz7gSaCiUJ/ew5HuTzXzS1NdNKaP407SIMjB8yKGmOTPlIf2Pe/i5kj6CVVV/ALPantfTddqpEXDjfSCYv+UwY5dKGKZXFzcbtQa9/LB+q1tPTi8Xo94CnxrSeJIzUJ6P3VXM9ihQn72GWrMz5Mt58VTf6yQve3gTq0gOIpLpMhOwwsWJ0tvOu5/4ma95qG4WTb7XKJIwmT6p0T7dBm8CJgigZckkme+Qob5nxUzg9pc1YQ9t5a3r0E1wCEdEeqXzBHgLfjjrF2ohYJwGWyw0zUqRxVYgrThpsIA9x4DqzFOAMRP76IV7WTCNTCoGslteV0hSFctBp1dAPUnN9bmvoWs4VS6n/tOxruYwEKLKGXzfo897LSMK6lEa1YF5r7BJIwSC1LxQ/60zuW8JYFV/4gyR3e7J2u0apExCZyohCDHgA/5Ol2mANuXIScFo6ikBJBKDWEN1GBjEA6aw0G0cY34JXJ73oN3JH8PRDmDAmd5RA3S97lyadPZxmvssN4pvncA6wj6ow3u27+TS+yDVYhtiri5Ce661Ps5sU9ih1EvxDvaoPTQnsWsvZ4/A3ybO0aNGDwlDcziNWV7+DZ6dDO6MHC93M6REYtDIjvtfSmnGC+8GNwoaa5rDCtrtbs4dSCYe3CIn9oLS03GgoTFdTpPKPNucwjN3yDIWGQ/HNJt2eCZvdVuljDWOiHR+1/rZZR/JNVvfhQRJdr+jT0gom0b9o/K3xh6N7VCPZMxalQHgELCkNa/+9WZ28L+6gpi7lEKy8dXdIoNlNE5Mpre/PiOHJViQi1ZquIM6h8fT09hv5bnT3s6zumdL7piUljRr00buCqWGF6UYNf3/3e6WeYPCKgxcDfVvi49AL4zrPKeEeW2KUwIAC0Vz0gbdh1bAAmdtjuQd4bXMMohcend69KV1nLByuLksmnyfHMXNnYmj0YHQI=--d+g/vj0Xg+A57CDr--GutJH+N6jhC1+wTQ52DuCA==
+1 -1
View File
@@ -1 +1 @@
qGtDqUHZuqVpajiHSIgxtaKdNgBjWFZaKMWitxGBAtV/q/P0uVAzry+MRonSMglb8SZEW28/LAKF28kUeAMGZVwnbMHAmcmOdOHNwfPBDu/3+c67J0qedEH2k8998o7wBqDi7iFTaWlDQROOi8LiiE3oVnpsRZGXtjXgClYI5ED062ITCd/tMLDsXiZjq/PhodWjI/YgEycwy0r1OldJZjOCzS1VD924AfOIH2/qtszc7oeArEpv8XYm3DJDCixYy5LLnhNIek7o8CHaZIeQxfm9pEh5ZEXaMNfSB4Gq+laAyLoXqsoPWuonjw0273Rjw1U3PU+6GlU85ZUrm9dBnekZiatLIHt3yX1CKynS8yqen0ON+MrP5W98pAa9Njq2GtB2SUrGY1xQtyoIfYPxFbNZoOph4PubcXqCT94bkAbZOiYKSiNLONuhhvKR6r9amh+LlDELp7QLR8EIfRpgoN5D8qhbhEi1/c2Sxogv/7+YtkpaYq47Ram2THll+URQ3L4iLKyPIaimxt9vsqdO5COn69fTqtqkYR/fKy93/3JIJxiNQ8I6z5xhGRLFk9ADWB3ZVz1VzXzW0Q1B0UQmMg0KEEM9ZQTxignWeJasbG2L/T656Lg/wPdKdAw0jcZAOh+BGtePgOOjZ5Le8rweSHKou0kTn1EjR81csRuSnHEEGTdlNp34LC0NirqwEwmPySRrQxmNwslS82R8uQvZQ+XtalJBBvUVwaB+o2rm9AfZFzzjGmZhVOKCQLZx4ugG+CUfc+soLGuQtcyPQe+0V2hJ97zzqdWxKvdUQ/aCerBwUQczl7r/PPxKucl4LtF+3mNYNMWjIdNIHJRzhvCnmZ8Zj1TuO4SGWMKNx7kwNP54dd4pqRD6Z0vm/x8zZXeccfu8Y39IOlawyEDR7idd0CSZ--41mPjeBDVAaVR2Q8--Qfq5LzxnQOYGDaGie2K6FQ==
lbv40Nq+bDCBc6DGVp3Q+CBuKLAt26v1vkKhOuUBJ/hPr07N98H13tejmM+C1GHF8DUJsTTsmSLYdYgelng0GDo9qhSpC/k+thZ1+Ne5pmZh00sH58PwItUNPBtLluqlpolRfNrJGMK3kjprWyTCbznAfGV6BZoT7Hbv12ZIri2bKsuTvsPZiIYLLAe2a2CWrNWjs4RezZbGWzbJpIyj+RIj3GKhGNCWNFhq79HyeRChaZNxioMaAgWBfAYKvXvkR2MuKEaspJM3lXnpnyxqVKWEWz4orhTCvlqbZchESDLF7hAuIkzXCXEC2EHpeEbzD0F52K/mLzT+kmi8dI+eLVimPJn0PZslccsHNAnXXHwvLvLOPFwzPz9N0jjGihu6yZKE1vl5r1dzo9mVWVTnsI5Z/g5SAmOtY+DB+pzssQdXSLFQZ/l2JcWvgYA9wCQyQbyGLEeirGe/QeCQciTkE+Z/bj7b+20rBgsRQIyHxNdzWDsmi4onJ75lIz6PA+1X4AIkwnAqUVzuBDwB+VxdwbzRZZeURMLBVzs76Ta/qSemIpuZ782cyt6MRmeYuAT6RMccnG/zwdvPHtpJ6C8fqKyOu+XkU2a49Yx/D6l/uvVtwiOayHTCwn2QmGYDlnniCBDJCzp0y0Y1M4d3XxAdV61XEL6rISMuM0mjdRZL3PYTpR8mrrflnYA4kFrLUnA+hEmcOq8stHSL1RYe83PI4dj6l1/hzQ6up2cGNBk+to7FXpQOhkuYV2+9xu+4PnJnl261G1D/v8zoWV+0OIn4ySC1o/t8BkvoYvFzDJkDIGYwNV6tTdhgitlZ3ljohwTj2HIclr0pZVwptQDJpe/ZYbpxq13uN0Xi6QdAWHJcUWzt3jZnX7N07nY34NW7DhM/D5SSDMkZbjPIPBS4t+n/lShh--MofA9sfR6Z6UuGQ2--jtVAHhYAxdvdXOJ/vb06Ug==
+1 -1
View File
@@ -1 +1 @@
gJofgOA5kUBwjcFwc6IDRjLbYQfG7qGpm8BcAEnS0FODuMbEE48FKN4jdUfhGAw0yCHu9DT1cJiP2wKR8s7nyf6dUGl6fPljR0sKTvO0eznmWigWCc+Ki6zVef0mRqQ2lZguiokjapG9c4qBcy2MYhVOhw3JAYvH6X4piiyO52nLL/kKLEQ64/uqC9VDKgR8TRhyVRqQN2jvB62VnIGxWX/qSPcapF/jw8ECgPsXgOSY/s4oNvQ8gCuZMweDlSYqFLAXpn2GP+wAaT9u6UmprPx7niDb1EIIm+LFWU1Ar4lY2n5Mo9ckN0ddsoMnwlsYTjlAWnIn9m0o/GbaxWmtSScnxHXbzqr630rEffvZrzLTG60dxkI0it8cVXwSHI6pMQHZw/lUkHd13IHMzR9eNGUq4pV+fdQjhvcfS2sTwVeCZ6VV1DbnY1vOeyMnVfvFLlJ7eqChkpFBAmOKS4hV/Mrqks6OP7K6ubyXt/REwuNhRMeJ1SJAfgiDSKbmNFtSnODyXKP1nFTCvvdndiP1H6t8qkJo8COSnSWWzcyyVKMwEKwbOxxtX2C+V4aySTllw0gFODLjQPxPBwqnkwcEcDEHxtsgImOYnOc0Ro/4faI1C5WdCuF6+VOBGpq1nI4HCQqepx4b/HZt/EyJSaQU08emhHht2vlCqJUN2yPUxnbCKBAbQhz0khRANBWIf8q0wfwSLaAqQHJ9FsZp4ztOC5U4lULzaMJh7dBUbv/9+mH6Yb3lFbg57178GYCgbzFwhvEOlEKBjfwDRFNmYfiVH4YYbZTIgQvu3tSywNNQJ4flu6TSjMC6zdOBi41BAIXH7j04zwpQV7Zndlu3xE1LTMbcLfBdqzOBGuRVD5KvQH5XFj0RfKTg2DN7Mo2XwSb7IZnl3Ga/Tuvb7DwKpRtql2RCYIYXwg5EnCYF3rw8J9m4tLzr9AFh3KALq+Lnfa0d1MysB1NQJ5XqtJ3jeYW+sxv4ivv9gpzQ9SZF26Z3rZoQHnb2S7usIgzEquiDyhAQr67AYOMJe5DKRw/QShJwYK+k+n/IrpQxUBnTrZvf2I47KF6UkhgWpZgmQaDB7WR1PePHVqJdkdQgqQzUU2t0SQIUY0FHH6MRLyHDcqOFiPl76HNFOmElZzrfXzqtwQdXOau7hV645LqaUy2xinA97zkEdpAGr/P/eloveh6wIgSIZNTsm8RbgRkl8SS1O9Q9goxMir4S3JvjWTJmAhxKaKlGeIVcMRVIv5C634Ugpp5jzh8PHX9nHAN2w9HHI+8AMaBUeFyuixbfk88EIvby72wY8MWxYzv1H/THWSL8WTiQJGxWE4PjSBQtwuc1zzHqG8Ve45Z1nIkt4TmC1VXftUHj5SghncAHCA27v+Hdk+cjSw/ymaVwIRreaZYMjOs2xX8XGOYXpbChFJaMk7ywR3FyBzOVvFXGnGCuA55f+j/7E2KuBI4lnr/mmCTeYvLOBrBDd2c1sEjKTURM4cSlDBw9NKc8oloxYZQKn9vA--z6fcV0Ojv6v39SCA--yOvnjjoQdp6k7P89GMEgYw==
z0tKJ9f/hCNXOi7b6lZlfzmFm8HAGNafiluMTcRlKmFxTm5ygs6fk0N0JNnJyrxsv6huye3/mptfrqkMOHRQcWzmZQyWDmayLxtmghRkpXmX92QZ8DiSs6jgWFX9zKuMPEW9eFpuLjstF1khPskg70SYVXBc3cTRJbyx5JGhnv1c3qOBfN4PKGYvTWsxacoQ356LYfu3haezmiS0yPzREo1rhFdwNyveV8uZ1QX1ZNZFp+WN5RXeiM632kL4R73KhwkbGBSHj5z9xxl0v4BzLrGM+Jf3VsJFmhYXS10mN+XOsNELCHUaM1wQOqMNHegBfupTzwur6Q3R154XP7rrBLquFT3wkAlkrI8ox+NRJgnGQ/yCc3PM8zhxgdrLzmr9s35eByKRCMsuIkF0WSfcBeWpCfNQaeucw10fuKirlI+G5JsX6HaRfAea3dUvVsuKCeutKn2/YPmw7Qodat2G9sZgUhBOYRzDcmzWaUyudciAMXRzNfoTZqngwFXDvfcAlmq4FrnucnfAsMB/TtqrZzV/td4nPfQLpxZCCl7v3hIggQUJl7gmdR335J6wJ4r8i0eTQjTw+j/Xm8wF1AEHpwxz2DOg8BjAI+/F8Lgqc+YDQCEYINYn+bwy5eMUv7H3aLDGrF3ZKnsEwWj62Q5LtHF/Fi7ZIOWgQyk4d5lLc4+T6hx4qe9iqu29LlduIxwJRG1dWPgNxuikPM2hcl6dQP8ebP9dNpdeeMCGylzR/kZjro7mAawxgIKEdOvWE6NJs8xGkjV2JjqWu7ufiIpk+jurUBlO5mIJuZhVvRumpMgcxVwqXWjJRt5BTVT/qcuLdaPcYY9hUt3TQrqjSGIt5bSFjcCFEfY15DxXsIVe84Oi4KYQNmuM4h2ub1zfYQYYH4UaGg7bLbb2OeQIIkkTwXXAR6o3BKJWtskOD3GTdamk3gKhudhm+BOgbOavoiE4hpvv/4ajjl8OGseGOAjC4bj8hpNnhezjQFRe2pK1515hi8iyYQ2tEScGy4wmsAbSaoA54+tHki0yeLUF8oJ9GTr9UCwnz8nUhQmfrS4VJO6Q/J4MXRCon+mAt3cyHaC6z3RISREw0a4aO3T2PGvUBVDEP0kfhDJmZXM5Of0RdFX27pwaQUPo3hqzQ+BmyenGqUonC1nPd/npojmRWXan9v5iMRKRx8qEBfhm+WQPEQuJqi5szm8pla6gUtNKaL6P4zhJOnIZiKxpYSlnezlMWNof/xcOb5BqYeEYQJ9FTvIxGrmN7OxQFlfk21z+n1OJI82TKIaKB/Oo+bsTtta8BuXONJUtsRSP9HcqMlMnwq2We4jn8JLFf/9O0s+iQtB6um7EAcHkE48jBHkMVzJVWB4/9Z8xe4DIwrg9BZH6UNL/pc/K5/dOeW8QoOPceBWmHP9BGoWFVqmNmEwPeNmnQZ5H/zxGPD94oG5YfiUMfMrOtIiAwS3Qqbmr+nAa5eNe4Ij7/i5nlV5gJ8y3NSNQvjico4iqBU1WAEHL6bHV--JcYezF+/1oGWkoz5--lepA3Pi+iuzJTgNRR4ax8Q==
+1 -1
View File
@@ -1 +1 @@
DHJSW4c/xFkMWWYW3b1uqnoiwuqVvRabPVvSKZWaoM+EPzUses5EaJ7GBXjpVS/4TYQ+0XsuLBTniuhUtFx56PHo1vWWFRy3n8I1YGPOs2X9mvG2wK5MlwSGO55xatQ1MabRyfjVJfW/inx4oWaqh4IsZhQ5uL/PUWwWy+7tKx3yWbPDa9K2vsgJuJJqZav0gJ7Bn5j8sdpj8xRofA0ZmiA3QxVIHOLiBYRXH9gmuzJ9BcDiAfZ1EA2FnM7T/2v5u6K4k5Lkp4Khn7t5kSF5hrUIuU7dWFaz4WFS6sfcCkRTHgPKu1ZfunzQl9vDXxkwVUjnfGnIk6XUQz8qfI1sDwhxjaiKAtn/6V8/zSo0v9lj91kfVWg/e8byzDxtZhRN71lnDgwaRIQxSDumfFIX2Yd5aXoQw7b+VymZaA+8J7vh18GPkBSsWTZ9b1fA1iVHncSDSEYH/FVXK3Ul/mpcN1bJrlBXMkZ0HmDA1EXhMJqPGosE6/OT5f1Zsn5SSpbqM6VZjQ6LOHwNCZs24F8BhGnCQtdl/VlIHKyGq8/zcyWUuROAVSxV9fiMDddWrprjDsjXPUigFqgtyco1CCZ9p6ScMihOzpOhT0dcvvjKZY56e3GR6n7u5JfSL9CpykQOXATHBXJlAc3HYxPxfESkEa31VmwZGOZxUsTpIZDLC/7VCHXGgtvOYLfoPBJBZ7BP8Eun+q/hz4iAr40xlfSeg3G18JpQZYHeF2/3WucrTW8NinwkPW/Yr/jtd9duyuIZNY2/zGyLiyoKiuHnk/iVDsyLIth1P7ZoQNyyiITzB/OQ2KqAvqwjnNYqLfBAQfe6grBdAGrNInsBHc7HNMcuHP9bvvS2cjlepP4zZyKo5oiAkmaWUPuBkUqmY6ZcOmwLTwl//fBXlUzCuUhR+0k7af6IlCsDhmp0HL575JmYN+HKGZ2LtQo+1v4MFJR5c2gh0Q4zTAkJsaOz+39aI5e2VPXkpXNyz+VVFJO36lg9tpaomUgtI2BjrO3Y3qQ8Y5jilnSX2HslL4lFMLDvyphDYJx7ODPimUjNkJ616YXSgFJSBUVa/ui1CnxNqOfO+AtuxJz26cuQw8ORKZNihd0QT276pHGhnXb+B0JT+XCuniZnSF+oCn6Kmbt1bsElCN/sMrII7UdsRGD5sQWicjpOM0g50GPYF9z6v8tCYlEmTQSjp7/KOwKLo4l4re3KeuD4FuK/1n0x2CNm1MLbvXcOGkmCa3P79YsKl8L1x8+hbNoHpEpswc0dPb6FLthCxCqrGQiREO4pbTrLasVL6CJA0eRd39B/8AcXnsJwNj4B3a2PbafnkdhJMPAuXvieFFN6leMXBF0YCsg+1Br8h9YmZfzmkECo9JVkNCfYdNbkfo8AA+hHu/youi6vcJD+x+eKTVdi88jy9qwyDdzIf8GdiIHy00PnfXj4lgTx7SQr+WUpsUYnQZh8xqkAuh5Lgi7ExqZMjjo4FORFxoJHToNU6c44+aVb36P9Fg6v3YedINYZkRWMTUq/f9O1zoOx3wIE10Qk4SkF+MLPSuLJWbEmfCyNZZwFUH7xde9lSDfB1s+1XhtEz0Li3Kq8jCeftRcIZvQ3LVjFuirf6nfZmZoTjaVDyUiWHtXyrQ==--NSFlvZtAfCQmzAp3--o8XatBC+Yh/p55fY485A7g==
tTZW5futjROLhOOU4sULsj8f7nCQ73Z7Pmh/3HT2Y1hxhz7MxKOREq2ZjwEFPhL2/c/zim/C9PzgNql6MDjtUzYmd26Q+2HzFSS2JpFBqU7DnEgL6mWC7x5s9feTG61ilOhmlLIwZRgNvugwRAUTlCv2KZ8R5iaUviPcwf6fl4dOhM7pW2Fx8MhuJQBSbjkoqisyniIzdCvgsE2s4qIkxBz+qrbjVNhmyTvl/jdttjTcIYlubCOlELZdbT4zNsitcs/8xBTBlxsR4Vt9rBcwr+EPrhQfpvGVKnSleeiatc6EOi0kAniqbjwJFkcpJ95PrU3yhV8TP7RAzHpOIhYHSwDcrQS2ltP9z7fVP2CRicpWEIh7iFR79JtIsp1cYH6pbx08ZAnKKeu240SKkKA/wZH5BzyOrVaTHwQ5q18U8hzWon6loMiQMV9+/jRZtb8rOTHU4IpUi/oM7shUZoHxccBq68ZxpclLEDZs7CLNytXbRq9oaCqyg5tW5qW+Lws+4iRlmR43hTq2SHJ+eMjq4N8WxAdecDGbgPdNFaoY6Ymw9YEwsYiWzHx7fRC0EUYckbUPEccT6b8IuYTJg/QeQQ8HVenboM9U/9Cd6SODR4Vdyn6fGkFmc7mfPke01naCUEconH7RL0EB38yz/MMqW355PhsaZqsv7PlCE7KJIxkejp3gwiBwP8petq4OazxZY2MJahYLwvMQwFt35qu9RJVAMiAMN9DLvRQu7w+oNA0g3OSGOwnIzHtjKMRks3W4YNEgwMGrq3YzI11EmORB/1F3t+UDDN8GOxxGgBmIdVrFHwSazrw5igqv/kTFsrzxSHW1ja3/U27JVYOSGDBMZ5TlAH7CSFD6SgbDHreauN/O0zuesTH9QnkUARhOHw75dfTEvacjy6uIcO9GmrRConJGWzJdZj5joBROhCtoflcn9jfce3W6YJfzHgveUxPnRU1bhaNULGTW/lA+cclDmm1jL2AhpDYzk1hq6/I5we0fY9/VNMgFnm+c/qrkjrXChFNCT4w+EU+n7TaXJ5ocWdCMTyM2/RSnm1dBX8wGoygryOYEnHM+6GDeX64vfGSnWZ2yfpsKrAzn7zVFWAY6Ah2YEXm+pQdUPB4CYwvg6A1liaCvF71P2p9SZgX79YEwGvsODMzrBavxVMIlVa92jtOZ23yoOjfR8gWleqeyoAOCN2JaM4rJfPrzZrs5LKDfYtgpR4Jg36LjD1UKWp3Kab+kHOpxzkPOgq6v8px3Rk3xaGADwCPnE+4qSTFByYaHCdx6FNeQQit3jSEq4h6WNeVkrKnZ6ybCnd7LwBKiu9uQURB6jar7t4IqM6kYThN9J7q/oxp3HS5+TkNT+1Org13CuNL9iF3M6n+R/U/vJCCRUMbM9+O8cYHEt5MJ875qBD1NRxj4UbVCcv5rDXhAFq+GuJqJTLN2+NPWVJ2QhZk1bhmwxIsZJ5vlWTMUXoOZOxUJrLqPK+v8h2x6G8J8Cc0WpzsHAl1B8peN7TxXJgcOyaVkHbPr2CYao/z7mG7D+5SIHcqcbNUB09Nh4iMVbQa1SEpTnWPqum1EwXkmH8QjUcj+IMsXcNWTVkaAfAsme50fMS8XGLiOU9AGfV5QcryaJQ6w250ewQ==--IKMr2eXgh2p/VO+z--2sCRrfBLg9TEjHH93qFmcw==
+1 -1
View File
@@ -1 +1 @@
UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg==
OCWdPtq1nxi6erX3KVmolRjjazqRbSQ102v9t/MUwHy2Af7CztQt+S0N2fGBNnGb0QTNGbzAlETRVUo7trc7+pjO8w7lyuHS/eKO84wuuPh7qRkrZfwxF2R0D2jmcaCa2S2Hfs8Ws7xFN2j9xCdjWJKd4khP4OygyLFK9wah9fWW+QvMl1evgnSA8Vtko1Eh+/bBQC4Lb3/tlsjeqfv/N229oPywD6uM6XfRsSS0/tuCp3w1MPhs2lb1ZfTwt4y5Dsk+r0YoUUr0CDPUAPSohmDJn5++8NiipIt7CQoGe72M4dp7WZ4eQAiprID8QIWAQ2I8RdvwbJdjFRKjcjgeB7vYiLUTRgmav8q/7QDk+JxWCU5oQxDmVex63CJ+sZ8NI94EnyppqHqihyLDzViwbBHkOmWj3WsgPOQHNjwhX+LwMeJ8UkWjAJlA7q59EqEDvPPN7MAgpunPuRXL6YXsS6UBpUmpof6fwMyhHbnFpRgTx0QyUFW3Ta7cs2JWCST2PXI5lGQKJBKIhcLB60bO1NtJYtX3xlheywnbNUzvsBTQO3j+ezPxcy07REvW1NfXQInpAgOudsZ4ScW0mblwiO5v/rLGJN5cMUNWAg==--8NVEpFCH347bJZoD--6fw4f/Fad/N8XQH6oTwpQg==
+1 -1
View File
@@ -3,7 +3,7 @@ default: &default
- polling_interval: 1
batch_size: 500
workers:
- queues: [ "default", "solid_queue_recurring", "backend" ]
- queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ]
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
+3
View File
@@ -26,6 +26,9 @@ production: &production
generate_weekly_highlights:
command: "User.generate_all_weekly_highlights_later"
schedule: every sunday at noon
cleanup_webhook_deliveries:
command: "Webhook::CleanupDeliveriesJob"
schedule: every 4 hours
beta: *production
staging: *production
+10
View File
@@ -20,6 +20,12 @@ Rails.application.routes.draw do
end
resources :cards, only: %i[ create ]
resources :webhooks do
scope module: "webhooks" do
resource :activation, only: %i[ create ]
end
end
end
namespace :cards do
@@ -170,6 +176,10 @@ Rails.application.routes.draw do
polymorphic_url(event.eventable, options)
end
resolve "Webhook" do |webhook, options|
route_for :collection_webhook, webhook.collection, webhook, options
end
get "up", to: "rails/health#show", as: :rails_health_check
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "service-worker" => "pwa#service_worker"
@@ -0,0 +1,13 @@
class CreateWebhooks < ActiveRecord::Migration[8.1]
def change
create_table :webhooks do |t|
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
end
end
end
@@ -0,0 +1,13 @@
class CreateWebhookDeliveries < ActiveRecord::Migration[8.1]
def change
create_table :webhook_deliveries do |t|
t.belongs_to :webhook, null: false, foreign_key: true
t.belongs_to :event, null: false, foreign_key: true
t.string :state, null: false
t.text :request
t.text :response
t.timestamps
end
end
end
@@ -0,0 +1,12 @@
class CreateWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1]
def change
create_table :webhook_delinquency_trackers do |t|
t.belongs_to :webhook, null: false, foreign_key: true
t.datetime :last_reset_at
t.integer :total_count, default: 0, null: false
t.integer :failed_count, default: 0, null: false
t.timestamps
end
end
end
@@ -0,0 +1,5 @@
class AddCollectionIdToWebhooks < ActiveRecord::Migration[8.1]
def change
add_reference :webhooks, :collection, null: false, foreign_key: true
end
end
@@ -0,0 +1,10 @@
class AddConsecutiveFailuresCountToWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1]
def change
add_column :webhook_delinquency_trackers, :consecutive_failures_count, :integer
add_column :webhook_delinquency_trackers, :first_failure_at, :datetime
remove_column :webhook_delinquency_trackers, :total_count, :integer
remove_column :webhook_delinquency_trackers, :failed_count, :integer
remove_column :webhook_delinquency_trackers, :last_reset_at, :datetime
end
end
Generated
+37 -1
View File
@@ -419,7 +419,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
t.datetime "created_at", null: false
t.string "title"
t.datetime "updated_at", null: false
t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true
end
create_table "user_settings", force: :cascade do |t|
@@ -456,6 +455,39 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
t.index ["user_id"], name: "index_watches_on_user_id"
end
create_table "webhook_delinquency_trackers", force: :cascade do |t|
t.integer "consecutive_failures_count"
t.datetime "created_at", null: false
t.datetime "first_failure_at"
t.datetime "updated_at", null: false
t.integer "webhook_id", null: false
t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id"
end
create_table "webhook_deliveries", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "event_id", null: false
t.text "request"
t.text "response"
t.string "state", null: false
t.datetime "updated_at", null: false
t.integer "webhook_id", null: false
t.index ["event_id"], name: "index_webhook_deliveries_on_event_id"
t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id"
end
create_table "webhooks", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.integer "collection_id", null: false
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
t.index ["collection_id"], name: "index_webhooks_on_collection_id"
end
create_table "workflow_stages", force: :cascade do |t|
t.string "color"
t.datetime "created_at", null: false
@@ -501,6 +533,10 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
add_foreign_key "user_settings", "users"
add_foreign_key "watches", "cards"
add_foreign_key "watches", "users"
add_foreign_key "webhook_delinquency_trackers", "webhooks"
add_foreign_key "webhook_deliveries", "events"
add_foreign_key "webhook_deliveries", "webhooks"
add_foreign_key "webhooks", "collections"
add_foreign_key "workflow_stages", "workflows"
# Virtual tables defined in this database.
+200 -18
View File
@@ -1248,7 +1248,7 @@ columns:
- *9
- *18
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &38 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *32
@@ -1318,6 +1318,131 @@ columns:
default_function:
collation:
comment:
webhook_delinquency_trackers:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: consecutive_failures_count
cast_type: *3
sql_type_metadata: *4
'null': true
default:
default_function:
collation:
comment:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: first_failure_at
cast_type: *1
sql_type_metadata: *2
'null': true
default:
default_function:
collation:
comment:
- *6
- *9
- &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: webhook_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
webhook_deliveries:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: event_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: request
cast_type: *15
sql_type_metadata: *16
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: response
cast_type: *15
sql_type_metadata: *16
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: state
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *9
- *37
webhooks:
- *38
- *24
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: name
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: signing_secret
cast_type: *7
sql_type_metadata: *8
'null': false
default:
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:
name: url
cast_type: *15
sql_type_metadata: *16
'null': false
default:
default_function:
collation:
comment:
workflow_stages:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -1397,6 +1522,9 @@ primary_keys:
user_settings: id
users: id
watches: id
webhook_delinquency_trackers: id
webhook_deliveries: id
webhooks: id
workflow_stages: id
workflows: id
data_sources:
@@ -1448,6 +1576,9 @@ data_sources:
user_settings: true
users: true
watches: true
webhook_delinquency_trackers: true
webhook_deliveries: true
webhooks: true
workflow_stages: true
workflows: true
indexes:
@@ -2858,23 +2989,7 @@ indexes:
nulls_not_distinct:
comment:
valid: true
tags:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: tags
name: index_tags_on_account_id_and_title
unique: true
columns:
- title
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
tags: []
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_settings
@@ -2991,6 +3106,73 @@ indexes:
nulls_not_distinct:
comment:
valid: true
webhook_delinquency_trackers:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: webhook_delinquency_trackers
name: index_webhook_delinquency_trackers_on_webhook_id
unique: false
columns:
- webhook_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
webhook_deliveries:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: webhook_deliveries
name: index_webhook_deliveries_on_event_id
unique: false
columns:
- event_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: webhook_deliveries
name: index_webhook_deliveries_on_webhook_id
unique: false
columns:
- webhook_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
webhooks:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: webhooks
name: index_webhooks_on_collection_id
unique: false
columns:
- collection_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
workflow_stages:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: workflow_stages
@@ -0,0 +1,19 @@
require "test_helper"
class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
webhook = webhooks(:inactive)
assert_not webhook.active?
assert_changes -> { webhook.reload.active? }, from: false, to: true do
post collection_webhook_activation_path(webhook.collection, webhook)
end
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
end
end
@@ -0,0 +1,115 @@
require "test_helper"
class WebhooksControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index" do
get collection_webhooks_path(collections(:writebook))
assert_response :success
end
test "show" do
webhook = webhooks(:active)
get collection_webhook_path(webhook.collection, webhook)
assert_response :success
end
test "new" do
get new_collection_webhook_path(collections(:writebook))
assert_response :success
assert_select "form"
end
test "create with valid params" do
collection = collections(:writebook)
assert_difference "Webhook.count", 1 do
post collection_webhooks_path(collection), params: {
webhook: {
name: "Test Webhook",
url: "https://example.com/webhook",
subscribed_actions: [ "", "card_published", "card_closed" ]
}
}
end
webhook = Webhook.order(id: :desc).first
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
assert_equal collection, webhook.collection
assert_equal "Test Webhook", webhook.name
assert_equal "https://example.com/webhook", webhook.url
assert_equal [ "card_published", "card_closed" ], webhook.subscribed_actions
end
test "create with invalid params" do
collection = collections(:writebook)
assert_no_difference "Webhook.count" do
post collection_webhooks_path(collection), params: {
webhook: {
name: "",
url: "invalid-url"
}
}
end
assert_response :unprocessable_entity
end
test "edit" do
webhook = webhooks(:active)
get edit_collection_webhook_path(webhook.collection, webhook)
assert_response :success
assert_select "form"
end
test "update with valid params" do
webhook = webhooks(:active)
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: "Updated Webhook",
subscribed_actions: [ "card_published" ]
}
}
webhook.reload
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
assert_equal "Updated Webhook", webhook.name
assert_equal [ "card_published" ], webhook.subscribed_actions
end
test "update with invalid params" do
webhook = webhooks(:active)
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: ""
}
}
assert_response :unprocessable_entity
assert_no_changes -> { webhook.reload.url } do
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: "Updated Webhook",
url: "https://different.com/webhook"
}
}
end
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
end
test "destroy" do
webhook = webhooks(:active)
assert_difference "Webhook.count", -1 do
delete collection_webhook_path(webhook.collection, webhook)
end
assert_redirected_to collection_webhooks_path(webhook.collection)
end
end
+11
View File
@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
active_webhook_tracker:
webhook: active
consecutive_failures_count: 1
first_failure_at: <%= 1.hour.ago %>
inactive_webhook_tracker:
webhook: inactive
consecutive_failures_count: 1
first_failure_at: <%= 1.hour.ago %>
+41
View File
@@ -0,0 +1,41 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
successfully_completed:
webhook: active
event: logo_published
state: completed
request: '<%= { headers: {} }.to_json %>'
response: '<%= { code: 200, headers: {} }.to_json %>'
created_at: <%= 1.week.ago %>
unsuccessfully_completed:
webhook: active
event: logo_assignment_jz
state: completed
request: '<%= { headers: {} }.to_json %>'
response: '<%= { code: 422, headers: {} }.to_json %>'
created_at: <%= 1.week.ago + 1.hour %>
errored:
webhook: active
event: layout_published
state: errored
request: '<%= { headers: {} }.to_json %>'
response: '<%= { error: "destination_unreachable" }.to_json %>'
created_at: <%= 1.week.ago %>
pending:
webhook: active
event: shipping_closed
state: pending
request: null
response: null
created_at: <%= 2.day.ago %>
in_progress:
webhook: active
event: logo_assignment_km
state: in_progress
request: null
response: null
created_at: <%= 1.day.ago %>
+15
View File
@@ -0,0 +1,15 @@
active:
active: true
name: Production API
url: https://api.example.com/webhooks
signing_secret: p94Bx2HjempCdYB4DTyZkY1b
subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'
collection: writebook
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 %>'
collection: private
@@ -0,0 +1,39 @@
require "test_helper"
class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase
test "record_delivery_of" do
tracker = webhook_delinquency_trackers(:active_webhook_tracker)
webhook = tracker.webhook
successful_delivery = webhook_deliveries(:successfully_completed)
failed_delivery = webhook_deliveries(:errored)
tracker.update!(consecutive_failures_count: 5)
tracker.record_delivery_of(successful_delivery)
tracker.reload
assert_equal 0, tracker.consecutive_failures_count
assert_nil tracker.first_failure_at
assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do
tracker.record_delivery_of(failed_delivery)
end
tracker.reload
assert_not_nil tracker.first_failure_at
assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do
assert_no_difference -> { tracker.reload.first_failure_at } do
tracker.record_delivery_of(failed_delivery)
end
end
travel_to 2.hours.from_now do
tracker.update!(consecutive_failures_count: 9)
webhook.activate
assert_changes -> { webhook.reload.active? }, from: true, to: false do
tracker.record_delivery_of(failed_delivery)
end
end
end
end
+217
View File
@@ -0,0 +1,217 @@
require "test_helper"
class Webhook::DeliveryTest < ActiveSupport::TestCase
test "create" do
webhook = webhooks(:active)
event = events(:layout_commented)
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
assert_equal "pending", delivery.state
end
test "succeeded" do
webhook = webhooks(:active)
event = events(:layout_commented)
delivery = Webhook::Delivery.new(
webhook: webhook,
event: event,
response: { code: 200 },
state: :completed
)
assert delivery.succeeded?
delivery.response[:code] = 422
assert_not delivery.succeeded?, "resonse must have a 2XX status"
delivery.response[:code] = 200
delivery.state = :pending
assert_not delivery.succeeded?, "state must be completed"
delivery.state = :in_progress
assert_not delivery.succeeded?, "state must be completed"
delivery.state = :errored
assert_not delivery.succeeded?, "state must be completed"
delivery.state = :completed
delivery.response[:error] = :destination_unreachable
assert_not delivery.succeeded?, "the response can't have an error"
end
test "deliver_later" do
delivery = webhook_deliveries(:pending)
assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do
delivery.deliver_later
end
end
test "deliver" do
delivery = webhook_deliveries(:pending)
stub_request(:post, delivery.webhook.url)
.to_return(status: 200, headers: { "content-type" => "application/json" })
assert_equal "pending", delivery.state
tracker = delivery.webhook.delinquency_tracker
tracker.update!(consecutive_failures_count: 0)
assert_no_difference -> { tracker.reload.consecutive_failures_count } do
delivery.deliver
end
assert delivery.persisted?
assert_equal "completed", delivery.state
assert delivery.request[:headers].present?
assert_equal 200, delivery.response[:code]
assert delivery.response[:error].blank?
assert delivery.succeeded?
end
test "deliver when the network timeouts" do
delivery = webhook_deliveries(:pending)
stub_request(:post, delivery.webhook.url).to_timeout
tracker = delivery.webhook.delinquency_tracker
assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do
delivery.deliver
end
assert_equal "completed", delivery.state
assert_equal "connection_timeout", delivery.response[:error]
assert_not delivery.succeeded?
end
test "deliver when the connection is refused" do
delivery = webhook_deliveries(:pending)
stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED)
delivery.deliver
assert_equal "completed", delivery.state
assert_equal "destination_unreachable", delivery.response[:error]
end
test "deliver when an SSL error occurs" do
delivery = webhook_deliveries(:pending)
stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError)
delivery.deliver
assert_equal "completed", delivery.state
assert_equal "failed_tls", delivery.response[:error]
end
test "deliver when an unexpected error occurs" do
delivery = webhook_deliveries(:pending)
stub_request(:post, delivery.webhook.url).to_raise(StandardError, "Unexpected error")
assert_raises(StandardError) do
delivery.deliver
end
assert_equal "errored", delivery.state
end
test "deliver with basecamp webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Basecamp",
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
)
event = events(:layout_commented)
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
request_stub = stub_request(:post, webhook.url)
.with do |request|
body = JSON.parse(request.body)
body.key?("line") && body["line"].key?("content") && body["line"]["content"].present?
end
.to_return(status: 200)
delivery.deliver
assert_requested request_stub
assert delivery.succeeded?
end
test "deliver with campfire webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Campfire",
url: "https://example.com/rooms/123/456-room-name/messages"
)
event = events(:layout_commented)
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
request_stub = stub_request(:post, webhook.url)
.with do |request|
request.body.is_a?(String) && !request.body.start_with?("{") && request.body.present?
end
.to_return(status: 200)
delivery.deliver
assert_requested request_stub
assert delivery.succeeded?
end
test "deliver with slack webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Slack",
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
)
event = events(:layout_commented)
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
request_stub = stub_request(:post, webhook.url)
.with do |request|
body = JSON.parse(request.body)
body.key?("text") && body["text"].present?
end
.to_return(status: 200)
delivery.deliver
assert_requested request_stub
assert delivery.succeeded?
end
test "deliver with generic webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Generic",
url: "https://example.com/webhook"
)
event = events(:layout_commented)
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
request_stub = stub_request(:post, webhook.url)
.with do |request|
body = JSON.parse(request.body)
body.present? && !body.key?("line") && !body.key?("text")
end
.to_return(status: 200)
delivery.deliver
assert_requested request_stub
assert delivery.succeeded?
end
test "cleanup" do
webhook = webhooks(:active)
event = events(:layout_commented)
fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago)
Webhook::Delivery.cleanup
assert Webhook::Delivery.exists?(fresh_delivery.id)
assert_not Webhook::Delivery.exists?(stale_delivery.id)
end
end
+106
View File
@@ -0,0 +1,106 @@
require "test_helper"
class WebhookTest < ActiveSupport::TestCase
test "create" do
webhook = Webhook.create! name: "Test", url: "https://example.com/webhook", collection: collections(:writebook)
assert webhook.persisted?
assert webhook.active?
assert webhook.signing_secret.present?
assert webhook.delinquency_tracker.present?
end
test "validates the url" do
webhook = Webhook.new name: "Test", collection: collections(:writebook)
assert_not webhook.valid?
assert_includes webhook.errors[:url], "not a URL"
webhook = Webhook.new name: "Test", collection: collections(:writebook), url: "not a url"
assert_not webhook.valid?
assert_includes webhook.errors[:url], "not a URL"
webhook = Webhook.new name: "NOTHING", collection: collections(:writebook), url: "example.com/webhook"
assert_not webhook.valid?
assert_includes webhook.errors[:url], "must use http or https"
webhook = Webhook.new name: "BLANK", collection: collections(:writebook), url: "//example.com/webhook"
assert_not webhook.valid?
assert_includes webhook.errors[:url], "must use http or https"
webhook = Webhook.new name: "GOPHER", collection: collections(:writebook), url: "gopher://example.com/webhook"
assert_not webhook.valid?
assert_includes webhook.errors[:url], "must use http or https"
webhook = Webhook.new name: "HTTP", collection: collections(:writebook), url: "http://example.com/webhook"
assert webhook.valid?
webhook = Webhook.new name: "HTTPS", collection: collections(:writebook), url: "https://example.com/webhook"
assert webhook.valid?
end
test "deactivate" do
webhook = webhooks(:active)
assert_changes -> { webhook.active? }, from: true, to: false do
webhook.deactivate
end
end
test "activate" do
webhook = webhooks(:inactive)
assert_changes -> { webhook.active? }, from: false, to: true do
webhook.activate
end
end
test "for_slack?" do
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
assert webhook.for_slack?
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678"
assert_not webhook.for_slack?
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678"
assert_not webhook.for_slack?
webhook = Webhook.new url: "https://hooks.slack.com/services/"
assert_not webhook.for_slack?
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_slack?
end
test "for_campfire?" do
webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/messages"
assert webhook.for_campfire?
webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123-test-room/messages"
assert webhook.for_campfire?
webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123/messages"
assert_not webhook.for_campfire?, "The bot key is missing a token"
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_campfire?
webhook = Webhook.new url: "https://example.com/rooms/123/messages"
assert_not webhook.for_campfire?
webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/"
assert_not webhook.for_campfire?
end
test "for_basecamp?" do
webhook = Webhook.new url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines"
assert webhook.for_basecamp?
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_basecamp?
webhook = Webhook.new url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/"
assert_not webhook.for_basecamp?
webhook = Webhook.new url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines"
assert_not webhook.for_basecamp?
end
end