Add administrative webhooks (#18510)
* Add administrative webhooks * Fix error when webhook is deleted before delivery worker runs
This commit is contained in:
		
							parent
							
								
									17ba5e1e61
								
							
						
					
					
						commit
						a2871cd747
					
				
					 33 changed files with 530 additions and 8 deletions
				
			
		
							
								
								
									
										19
									
								
								app/controllers/admin/webhooks/secrets_controller.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/controllers/admin/webhooks/secrets_controller.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Admin | ||||
|   class Webhooks::SecretsController < BaseController | ||||
|     before_action :set_webhook | ||||
| 
 | ||||
|     def rotate | ||||
|       authorize @webhook, :rotate_secret? | ||||
|       @webhook.rotate_secret! | ||||
|       redirect_to admin_webhook_path(@webhook) | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     def set_webhook | ||||
|       @webhook = Webhook.find(params[:webhook_id]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										77
									
								
								app/controllers/admin/webhooks_controller.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/controllers/admin/webhooks_controller.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,77 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Admin | ||||
|   class WebhooksController < BaseController | ||||
|     before_action :set_webhook, except: [:index, :new, :create] | ||||
| 
 | ||||
|     def index | ||||
|       authorize :webhook, :index? | ||||
| 
 | ||||
|       @webhooks = Webhook.page(params[:page]) | ||||
|     end | ||||
| 
 | ||||
|     def new | ||||
|       authorize :webhook, :create? | ||||
| 
 | ||||
|       @webhook = Webhook.new | ||||
|     end | ||||
| 
 | ||||
|     def create | ||||
|       authorize :webhook, :create? | ||||
| 
 | ||||
|       @webhook = Webhook.new(resource_params) | ||||
| 
 | ||||
|       if @webhook.save | ||||
|         redirect_to admin_webhook_path(@webhook) | ||||
|       else | ||||
|         render :new | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def show | ||||
|       authorize @webhook, :show? | ||||
|     end | ||||
| 
 | ||||
|     def edit | ||||
|       authorize @webhook, :update? | ||||
|     end | ||||
| 
 | ||||
|     def update | ||||
|       authorize @webhook, :update? | ||||
| 
 | ||||
|       if @webhook.update(resource_params) | ||||
|         redirect_to admin_webhook_path(@webhook) | ||||
|       else | ||||
|         render :show | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def enable | ||||
|       authorize @webhook, :enable? | ||||
|       @webhook.enable! | ||||
|       redirect_to admin_webhook_path(@webhook) | ||||
|     end | ||||
| 
 | ||||
|     def disable | ||||
|       authorize @webhook, :disable? | ||||
|       @webhook.disable! | ||||
|       redirect_to admin_webhook_path(@webhook) | ||||
|     end | ||||
| 
 | ||||
|     def destroy | ||||
|       authorize @webhook, :destroy? | ||||
|       @webhook.destroy! | ||||
|       redirect_to admin_webhooks_path | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     def set_webhook | ||||
|       @webhook = Webhook.find(params[:id]) | ||||
|     end | ||||
| 
 | ||||
|     def resource_params | ||||
|       params.require(:webhook).permit(:url, events: []) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -203,6 +203,14 @@ $content-width: 840px; | |||
|         } | ||||
|       } | ||||
| 
 | ||||
|       h2 small { | ||||
|         font-size: 12px; | ||||
|         display: block; | ||||
|         font-weight: 500; | ||||
|         color: $darker-text-color; | ||||
|         line-height: 18px; | ||||
|       } | ||||
| 
 | ||||
|       @media screen and (max-width: $no-columns-breakpoint) { | ||||
|         border-bottom: 0; | ||||
|         padding-bottom: 0; | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: admin_action_logs | ||||
|  |  | |||
|  | @ -55,6 +55,8 @@ class Report < ApplicationRecord | |||
| 
 | ||||
|   before_validation :set_uri, only: :create | ||||
| 
 | ||||
|   after_create_commit :trigger_webhooks | ||||
| 
 | ||||
|   def object_type | ||||
|     :flag | ||||
|   end | ||||
|  | @ -143,4 +145,8 @@ class Report < ApplicationRecord | |||
| 
 | ||||
|     errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size | ||||
|   end | ||||
| 
 | ||||
|   def trigger_webhooks | ||||
|     TriggerWebhookWorker.perform_async('report.created', 'Report', id) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -37,7 +37,6 @@ | |||
| #  sign_in_token_sent_at     :datetime | ||||
| #  webauthn_id               :string | ||||
| #  sign_up_ip                :inet | ||||
| #  skip_sign_in_token        :boolean | ||||
| # | ||||
| 
 | ||||
| class User < ApplicationRecord | ||||
|  | @ -120,6 +119,7 @@ class User < ApplicationRecord | |||
|   before_validation :sanitize_languages | ||||
|   before_create :set_approved | ||||
|   after_commit :send_pending_devise_notifications | ||||
|   after_create_commit :trigger_webhooks | ||||
| 
 | ||||
|   # This avoids a deprecation warning from Rails 5.1 | ||||
|   # It seems possible that a future release of devise-two-factor will | ||||
|  | @ -182,7 +182,9 @@ class User < ApplicationRecord | |||
|   end | ||||
| 
 | ||||
|   def update_sign_in!(new_sign_in: false) | ||||
|     old_current, new_current = current_sign_in_at, Time.now.utc | ||||
|     old_current = current_sign_in_at | ||||
|     new_current = Time.now.utc | ||||
| 
 | ||||
|     self.last_sign_in_at     = old_current || new_current | ||||
|     self.current_sign_in_at  = new_current | ||||
| 
 | ||||
|  | @ -472,4 +474,8 @@ class User < ApplicationRecord | |||
|   def invite_text_required? | ||||
|     Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check? | ||||
|   end | ||||
| 
 | ||||
|   def trigger_webhooks | ||||
|     TriggerWebhookWorker.perform_async('account.created', 'Account', account_id) | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										58
									
								
								app/models/webhook.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								app/models/webhook.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: webhooks | ||||
| # | ||||
| #  id         :bigint(8)        not null, primary key | ||||
| #  url        :string           not null | ||||
| #  events     :string           default([]), not null, is an Array | ||||
| #  secret     :string           default(""), not null | ||||
| #  enabled    :boolean          default(TRUE), not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
| # | ||||
| 
 | ||||
| class Webhook < ApplicationRecord | ||||
|   EVENTS = %w( | ||||
|     account.created | ||||
|     report.created | ||||
|   ).freeze | ||||
| 
 | ||||
|   scope :enabled, -> { where(enabled: true) } | ||||
| 
 | ||||
|   validates :url, presence: true, url: true | ||||
|   validates :secret, presence: true, length: { minimum: 12 } | ||||
|   validates :events, presence: true | ||||
| 
 | ||||
|   validate :validate_events | ||||
| 
 | ||||
|   before_validation :strip_events | ||||
|   before_validation :generate_secret | ||||
| 
 | ||||
|   def rotate_secret! | ||||
|     update!(secret: SecureRandom.hex(20)) | ||||
|   end | ||||
| 
 | ||||
|   def enable! | ||||
|     update!(enabled: true) | ||||
|   end | ||||
| 
 | ||||
|   def disable! | ||||
|     update!(enabled: false) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def validate_events | ||||
|     errors.add(:events, :invalid) if events.any? { |e| !EVENTS.include?(e) } | ||||
|   end | ||||
| 
 | ||||
|   def strip_events | ||||
|     self.events = events.map { |str| str.strip.presence }.compact if events.present? | ||||
|   end | ||||
| 
 | ||||
|   def generate_secret | ||||
|     self.secret = SecureRandom.hex(20) if secret.blank? | ||||
|   end | ||||
| end | ||||
							
								
								
									
										35
									
								
								app/policies/webhook_policy.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/policies/webhook_policy.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class WebhookPolicy < ApplicationPolicy | ||||
|   def index? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def create? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def show? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def update? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def enable? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def disable? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def rotate_secret? | ||||
|     admin? | ||||
|   end | ||||
| 
 | ||||
|   def destroy? | ||||
|     admin? | ||||
|   end | ||||
| end | ||||
							
								
								
									
										13
									
								
								app/presenters/webhooks/event_presenter.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/presenters/webhooks/event_presenter.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Webhooks::EventPresenter < ActiveModelSerializers::Model | ||||
|   attributes :type, :created_at, :object | ||||
| 
 | ||||
|   def initialize(type, object) | ||||
|     super() | ||||
| 
 | ||||
|     @type       = type | ||||
|     @created_at = Time.now.utc | ||||
|     @object     = object | ||||
|   end | ||||
| end | ||||
|  | @ -1,7 +1,8 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class REST::Admin::ReportSerializer < ActiveModel::Serializer | ||||
|   attributes :id, :action_taken, :category, :comment, :created_at, :updated_at | ||||
|   attributes :id, :action_taken, :action_taken_at, :category, :comment, | ||||
|              :created_at, :updated_at | ||||
| 
 | ||||
|   has_one :account, serializer: REST::Admin::AccountSerializer | ||||
|   has_one :target_account, serializer: REST::Admin::AccountSerializer | ||||
|  |  | |||
							
								
								
									
										26
									
								
								app/serializers/rest/admin/webhook_event_serializer.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/serializers/rest/admin/webhook_event_serializer.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class REST::Admin::WebhookEventSerializer < ActiveModel::Serializer | ||||
|   def self.serializer_for(model, options) | ||||
|     case model.class.name | ||||
|     when 'Account' | ||||
|       REST::Admin::AccountSerializer | ||||
|     when 'Report' | ||||
|       REST::Admin::ReportSerializer | ||||
|     else | ||||
|       super | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   attributes :event, :created_at | ||||
| 
 | ||||
|   has_one :virtual_object, key: :object | ||||
| 
 | ||||
|   def virtual_object | ||||
|     object.object | ||||
|   end | ||||
| 
 | ||||
|   def event | ||||
|     object.type | ||||
|   end | ||||
| end | ||||
|  | @ -5,4 +5,8 @@ class BaseService | |||
|   include ActionView::Helpers::SanitizeHelper | ||||
| 
 | ||||
|   include RoutingHelper | ||||
| 
 | ||||
|   def call(*) | ||||
|     raise NotImplementedError | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										22
									
								
								app/services/webhook_service.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/services/webhook_service.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class WebhookService < BaseService | ||||
|   def call(event, object) | ||||
|     @event  = Webhooks::EventPresenter.new(event, object) | ||||
|     @body   = serialize_event | ||||
| 
 | ||||
|     webhooks_for_event.each do |webhook_id| | ||||
|       Webhooks::DeliveryWorker.perform_async(webhook_id, @body) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def webhooks_for_event | ||||
|     Webhook.enabled.where('? = ANY(events)', @event.type).pluck(:id) | ||||
|   end | ||||
| 
 | ||||
|   def serialize_event | ||||
|     Oj.dump(ActiveModelSerializers::SerializableResource.new(@event, serializer: REST::Admin::WebhookEventSerializer, scope: nil, scope_name: :current_user).as_json) | ||||
|   end | ||||
| end | ||||
|  | @ -2,7 +2,7 @@ | |||
| 
 | ||||
| class URLValidator < ActiveModel::EachValidator | ||||
|   def validate_each(record, attribute, value) | ||||
|     record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value) | ||||
|     record.errors.add(attribute, :invalid) unless compliant?(value) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  |  | |||
							
								
								
									
										11
									
								
								app/views/admin/webhooks/_form.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/views/admin/webhooks/_form.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| = simple_form_for @webhook, url: @webhook.new_record? ? admin_webhooks_path : admin_webhook_path(@webhook) do |f| | ||||
|   = render 'shared/error_messages', object: @webhook | ||||
| 
 | ||||
|   .fields-group | ||||
|     = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' } | ||||
| 
 | ||||
|   .fields-group | ||||
|     = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
| 
 | ||||
|   .actions | ||||
|     = f.button :button, @webhook.new_record? ? t('admin.webhooks.add_new') : t('generic.save_changes'), type: :submit | ||||
							
								
								
									
										19
									
								
								app/views/admin/webhooks/_webhook.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/views/admin/webhooks/_webhook.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| .applications-list__item | ||||
|   = link_to admin_webhook_path(webhook), class: 'announcements-list__item__title' do | ||||
|     = fa_icon 'inbox' | ||||
|     = webhook.url | ||||
| 
 | ||||
|   .announcements-list__item__action-bar | ||||
|     .announcements-list__item__meta | ||||
|       - if webhook.enabled? | ||||
|         %span.positive-hint= t('admin.webhooks.enabled') | ||||
|       - else | ||||
|         %span.negative-hint= t('admin.webhooks.disabled') | ||||
| 
 | ||||
|       • | ||||
| 
 | ||||
|       %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size) | ||||
| 
 | ||||
|     %div | ||||
|       = table_link_to 'pencil', t('admin.webhooks.edit'), edit_admin_webhook_path(webhook) if can?(:update, webhook) | ||||
|       = table_link_to 'trash', t('admin.webhooks.delete'), admin_webhook_path(webhook), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, webhook) | ||||
							
								
								
									
										4
									
								
								app/views/admin/webhooks/edit.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/views/admin/webhooks/edit.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| - content_for :page_title do | ||||
|   = t('admin.webhooks.edit') | ||||
| 
 | ||||
| = render partial: 'form' | ||||
							
								
								
									
										18
									
								
								app/views/admin/webhooks/index.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/views/admin/webhooks/index.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| - content_for :page_title do | ||||
|   = t('admin.webhooks.title') | ||||
| 
 | ||||
| - content_for :heading_actions do | ||||
|   = link_to t('admin.webhooks.add_new'), new_admin_webhook_path, class: 'button' if can?(:create, :webhook) | ||||
| 
 | ||||
| %p= t('admin.webhooks.description_html') | ||||
| 
 | ||||
| %hr.spacer/ | ||||
| 
 | ||||
| - if @webhooks.empty? | ||||
|   %div.muted-hint.center-text | ||||
|     = t 'admin.webhooks.empty' | ||||
| - else | ||||
|   .applications-list | ||||
|     = render partial: 'webhook', collection: @webhooks | ||||
| 
 | ||||
|   = paginate @webhooks | ||||
							
								
								
									
										4
									
								
								app/views/admin/webhooks/new.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/views/admin/webhooks/new.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| - content_for :page_title do | ||||
|   = t('admin.webhooks.new') | ||||
| 
 | ||||
| = render partial: 'form' | ||||
							
								
								
									
										34
									
								
								app/views/admin/webhooks/show.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/views/admin/webhooks/show.html.haml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| - content_for :page_title do | ||||
|   = t('admin.webhooks.title') | ||||
| 
 | ||||
| - content_for :heading do | ||||
|   %h2 | ||||
|     %small | ||||
|       = fa_icon 'inbox' | ||||
|       = t('admin.webhooks.webhook') | ||||
|     = @webhook.url | ||||
| 
 | ||||
| - content_for :heading_actions do | ||||
|   = link_to t('admin.webhooks.edit'), edit_admin_webhook_path, class: 'button' if can?(:update, @webhook) | ||||
| 
 | ||||
| .table-wrapper | ||||
|   %table.table.horizontal-table | ||||
|     %tbody | ||||
|       %tr | ||||
|         %th= t('admin.webhooks.status') | ||||
|         %td | ||||
|           - if @webhook.enabled? | ||||
|             %span.positive-hint= t('admin.webhooks.enabled') | ||||
|             = table_link_to 'power-off', t('admin.webhooks.disable'), disable_admin_webhook_path(@webhook), method: :post if can?(:disable, @webhook) | ||||
|           - else | ||||
|             %span.negative-hint= t('admin.webhooks.disabled') | ||||
|             = table_link_to 'power-off', t('admin.webhooks.enable'), enable_admin_webhook_path(@webhook), method: :post if can?(:enable, @webhook) | ||||
|       %tr | ||||
|         %th= t('admin.webhooks.events') | ||||
|         %td | ||||
|           %abbr{ title: @webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: @webhook.events.size) | ||||
|       %tr | ||||
|         %th= t('admin.webhooks.secret') | ||||
|         %td | ||||
|           %samp= @webhook.secret | ||||
|           = table_link_to 'refresh', t('admin.webhooks.rotate_secret'), rotate_admin_webhook_secret_path(@webhook), method: :post if can?(:rotate_secret, @webhook) | ||||
|  | @ -23,7 +23,10 @@ | |||
|     .content-wrapper | ||||
|       .content | ||||
|         .content-heading | ||||
|           %h2= yield :page_title | ||||
|           - if content_for?(:heading) | ||||
|             = yield :heading | ||||
|           - else | ||||
|             %h2= yield :page_title | ||||
| 
 | ||||
|           - if :heading_actions | ||||
|             .content-heading-actions | ||||
|  |  | |||
							
								
								
									
										12
									
								
								app/workers/trigger_webhook_worker.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/workers/trigger_webhook_worker.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class TriggerWebhookWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   def perform(event, class_name, id) | ||||
|     object = class_name.constantize.find(id) | ||||
|     WebhookService.new.call(event, object) | ||||
|   rescue ActiveRecord::RecordNotFound | ||||
|     true | ||||
|   end | ||||
| end | ||||
							
								
								
									
										37
									
								
								app/workers/webhooks/delivery_worker.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/workers/webhooks/delivery_worker.rb
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Webhooks::DeliveryWorker | ||||
|   include Sidekiq::Worker | ||||
|   include JsonLdHelper | ||||
| 
 | ||||
|   sidekiq_options queue: 'push', retry: 16, dead: false | ||||
| 
 | ||||
|   def perform(webhook_id, body) | ||||
|     @webhook   = Webhook.find(webhook_id) | ||||
|     @body      = body | ||||
|     @response  = nil | ||||
| 
 | ||||
|     perform_request | ||||
|   rescue ActiveRecord::RecordNotFound | ||||
|     true | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def perform_request | ||||
|     request = Request.new(:post, @webhook.url, body: @body) | ||||
| 
 | ||||
|     request.add_headers( | ||||
|       'Content-Type' => 'application/json', | ||||
|       'X-Hub-Signature' => "sha256=#{signature}" | ||||
|     ) | ||||
| 
 | ||||
|     request.perform do |response| | ||||
|       raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def signature | ||||
|     OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), @webhook.secret, @body) | ||||
|   end | ||||
| end | ||||
		Reference in a new issue