mirror of https://github.com/docusealco/docuseal
				
				
				
			
							parent
							
								
									1b60b42428
								
							
						
					
					
						commit
						988a5361a6
					
				| @ -0,0 +1,40 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class WebhookEventsController < ApplicationController | ||||
|   load_and_authorize_resource :webhook_url, parent: false, only: %i[show resend], id_param: :webhook_id | ||||
| 
 | ||||
|   def show | ||||
|     @webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id]) | ||||
|     @webhook_attempts = @webhook_event.webhook_attempts.order(created_at: :desc) | ||||
| 
 | ||||
|     return unless current_ability.can?(:read, @webhook_event.record) | ||||
| 
 | ||||
|     @data = | ||||
|       case @webhook_event.event_type | ||||
|       when 'form.started', 'form.completed', 'form.declined', 'form.viewed' | ||||
|         Submitters::SerializeForWebhook.call(@webhook_event.record) | ||||
|       when 'submission.created', 'submission.completed', 'submission.expired' | ||||
|         Submissions::SerializeForApi.call(@webhook_event.record) | ||||
|       when 'template.created', 'template.updated' | ||||
|         Templates::SerializeForApi.call(@webhook_event.record) | ||||
|       when 'submission.archived' | ||||
|         @webhook_event.record.as_json(only: %i[id archived_at]) | ||||
|       end | ||||
|   end | ||||
| 
 | ||||
|   def resend | ||||
|     @webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id]) | ||||
| 
 | ||||
|     id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first) | ||||
| 
 | ||||
|     WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async( | ||||
|       id_key => @webhook_event.record_id, | ||||
|       'webhook_url_id' => @webhook_event.webhook_url_id, | ||||
|       'event_uuid' => @webhook_event.uuid, | ||||
|       'attempt' => SendWebhookRequest::MANUAL_ATTEMPT, | ||||
|       'last_status' => 0 | ||||
|     ) | ||||
| 
 | ||||
|     head :ok | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,26 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class SendTestWebhookRequestJob | ||||
|   include Sidekiq::Job | ||||
| 
 | ||||
|   sidekiq_options retry: 0 | ||||
| 
 | ||||
|   USER_AGENT = 'DocuSeal.com Webhook' | ||||
| 
 | ||||
|   def perform(params = {}) | ||||
|     submitter = Submitter.find(params['submitter_id']) | ||||
|     webhook_url = WebhookUrl.find(params['webhook_url_id']) | ||||
| 
 | ||||
|     return unless webhook_url && submitter | ||||
| 
 | ||||
|     Faraday.post(webhook_url.url, | ||||
|                  { | ||||
|                    event_type: 'form.completed', | ||||
|                    timestamp: Time.current.iso8601, | ||||
|                    data: Submitters::SerializeForWebhook.call(submitter) | ||||
|                  }.to_json, | ||||
|                  'Content-Type' => 'application/json', | ||||
|                  'User-Agent' => USER_AGENT, | ||||
|                  **webhook_url.secret.to_h) | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,25 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: webhook_attempts | ||||
| # | ||||
| #  id                   :bigint           not null, primary key | ||||
| #  attempt              :integer          not null | ||||
| #  response_body        :text | ||||
| #  response_status_code :integer          not null | ||||
| #  created_at           :datetime         not null | ||||
| #  updated_at           :datetime         not null | ||||
| #  webhook_event_id     :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_webhook_attempts_on_webhook_event_id  (webhook_event_id) | ||||
| # | ||||
| class WebhookAttempt < ApplicationRecord | ||||
|   belongs_to :webhook_event | ||||
| 
 | ||||
|   def success? | ||||
|     response_status_code.to_i / 100 == 2 | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,32 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: webhook_events | ||||
| # | ||||
| #  id             :bigint           not null, primary key | ||||
| #  event_type     :string           not null | ||||
| #  record_type    :string           not null | ||||
| #  status         :string           not null | ||||
| #  uuid           :string           not null | ||||
| #  created_at     :datetime         not null | ||||
| #  updated_at     :datetime         not null | ||||
| #  account_id     :bigint           not null | ||||
| #  record_id      :bigint           not null | ||||
| #  webhook_url_id :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_webhook_events_error                       (webhook_url_id,id) WHERE ((status)::text = 'error'::text) | ||||
| #  index_webhook_events_on_uuid_and_webhook_url_id  (uuid,webhook_url_id) UNIQUE | ||||
| #  index_webhook_events_on_webhook_url_id_and_id    (webhook_url_id,id) | ||||
| # | ||||
| class WebhookEvent < ApplicationRecord | ||||
|   attribute :uuid, :string, default: -> { SecureRandom.uuid } | ||||
| 
 | ||||
|   belongs_to :webhook_url, optional: true | ||||
|   belongs_to :account, optional: true | ||||
|   belongs_to :record, polymorphic: true, optional: true | ||||
| 
 | ||||
|   has_many :webhook_attempts, dependent: nil | ||||
| end | ||||
| @ -0,0 +1,65 @@ | ||||
| <%= render 'shared/turbo_drawer', title: @webhook_event.event_type, close_after_submit: false do %> | ||||
|   <div class="relative px-4 py-4"> | ||||
|     <ol class="relative border-s border-base-300 space-y-6 ml-3"> | ||||
|       <% if @webhook_event.status == 'error' %> | ||||
|         <% last_attempt = @webhook_attempts.select { |e| SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(e.attempt) }.max_by(&:attempt) %> | ||||
|         <% if SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(last_attempt&.attempt) %> | ||||
|           <li class="ml-7"> | ||||
|             <span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center border-base-content-/60 text-base-content/60 bg-base-100" style="left: -12px;"> | ||||
|               <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %> | ||||
|             </span> | ||||
|             <p class="leading-none text-base-content/90 pt-1"> | ||||
|               <%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %> | ||||
|             </p> | ||||
|           </li> | ||||
|         <% end %> | ||||
|       <% end %> | ||||
|       <% if @webhook_attempts.present? %> | ||||
|         <% @webhook_attempts.each do |webhook_attempt| %> | ||||
|           <li class="ml-7"> | ||||
|             <span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center <%= webhook_attempt.success? ? 'btn-success bg-lime-50' : 'btn-error bg-red-50' %>" style="left: -12px;"> | ||||
|               <%= svg_icon(webhook_attempt.success? ? 'check' : 'x', class: 'w-4 h-4 shrink-0') %> | ||||
|             </span> | ||||
|             <p class="leading-none text-sm text-base-content/60 pt-1"> | ||||
|               <%= l(webhook_attempt.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %> | ||||
|             </p> | ||||
|             <div class="mt-2"> | ||||
|               <p class="text-sm font-bold text-base-content/80"> | ||||
|                 <span><%= Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %></span> | ||||
|                 <% if webhook_attempt.response_status_code.positive? %> | ||||
|                   <span>(<%= webhook_attempt.response_status_code %>)</span> | ||||
|                 <% end %> | ||||
|               </p> | ||||
|               <% unless webhook_attempt.success? %> | ||||
|                 <p class="text-sm text-base-content/80 mt-1"> | ||||
|                   <%= webhook_attempt.response_body.presence || Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %> | ||||
|                 </p> | ||||
|               <% end %> | ||||
|             </div> | ||||
|           </li> | ||||
|         <% end %> | ||||
|       <% else %> | ||||
|         <li class="ml-7"> | ||||
|           <span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center btn-info bg-blue-50" style="left: -12px;"> | ||||
|             <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %> | ||||
|           </span> | ||||
|           <p class="leading-none text-base-content/60 pt-1"> | ||||
|             <%= l(@webhook_event.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %> | ||||
|           </p> | ||||
|         </li> | ||||
|       <% end %> | ||||
|     </ol> | ||||
|     <% unless @webhook_event.status == 'pending' %> | ||||
|       <%= button_to button_title(title: t('resend'), disabled_with: 'sending', icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid), class: 'absolute right-4 top-3 btn btn-neutral btn-sm text-white', method: :post %> | ||||
|     <% end %> | ||||
|     <% if @data %> | ||||
|       <div class="mockup-code overflow-hidden relative pb-0 mt-6"> | ||||
|         <% response = JSON.pretty_generate({ event_type: @webhook_event.event_type, timestamp: @webhook_event.created_at.as_json, data: @data }) %> | ||||
|         <span class="top-0 right-0 absolute"> | ||||
|           <%= render 'shared/clipboard_copy', icon: 'copy', text: response, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> | ||||
|         </span> | ||||
|         <pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden text-sm w-full"><%== HighlightCode.call(response, 'JSON', theme: 'base16.dark') %></code></pre> | ||||
|       </div> | ||||
|     <% end %> | ||||
|   </div> | ||||
| <% end %> | ||||
| @ -0,0 +1,21 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Rouge | ||||
|   autoload :InheritableHash, 'rouge/util' | ||||
|   autoload :Token, 'rouge/token' | ||||
|   autoload :Lexer, 'rouge/lexer' | ||||
|   autoload :RegexLexer, 'rouge/regex_lexer' | ||||
| 
 | ||||
|   module Lexers | ||||
|     autoload :JSON, 'rouge/lexers/json' | ||||
|   end | ||||
| 
 | ||||
|   autoload :Formatter, 'rouge/formatter' | ||||
| 
 | ||||
|   module Formatters | ||||
|     autoload :HTML, 'rouge/formatters/html' | ||||
|     autoload :HTMLInline, 'rouge/formatters/html_inline' | ||||
|   end | ||||
| 
 | ||||
|   autoload :Theme, 'rouge/theme' | ||||
| end | ||||
| @ -0,0 +1,30 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class CreateWebhookEventsAndAttempts < ActiveRecord::Migration[8.0] | ||||
|   def change | ||||
|     create_table :webhook_events do |t| | ||||
|       t.string :uuid, null: false | ||||
|       t.bigint :webhook_url_id, null: false | ||||
|       t.bigint :account_id, null: false | ||||
|       t.bigint :record_id, null: false | ||||
|       t.string :record_type, null: false | ||||
|       t.string :event_type, null: false | ||||
|       t.string :status, null: false | ||||
| 
 | ||||
|       t.index %i[uuid webhook_url_id], unique: true | ||||
|       t.index %i[webhook_url_id id] | ||||
|       t.index %i[webhook_url_id id], where: "status = 'error'", name: 'index_webhook_events_error' | ||||
| 
 | ||||
|       t.timestamps | ||||
|     end | ||||
| 
 | ||||
|     create_table :webhook_attempts do |t| | ||||
|       t.bigint :webhook_event_id, null: false, index: true | ||||
|       t.text :response_body | ||||
|       t.integer :response_status_code, null: false | ||||
|       t.integer :attempt, null: false | ||||
| 
 | ||||
|       t.timestamps | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,15 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module HighlightCode | ||||
|   module_function | ||||
| 
 | ||||
|   def call(code, lexer, theme: 'base16.light') | ||||
|     require 'rouge/themes/base16' unless Rouge::Theme.registry[theme] | ||||
| 
 | ||||
|     formatter = Rouge::Formatters::HTMLInline.new(theme) | ||||
|     lexer = Rouge::Lexers.const_get(lexer.to_sym).new | ||||
|     formatted_code = formatter.format(lexer.lex(code)) | ||||
|     formatted_code = formatted_code.gsub('background-color: #181818', '') if theme == 'base16.dark' | ||||
|     formatted_code | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,97 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| RSpec.describe SendSubmissionExpiredWebhookRequestJob do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:user) { create(:user, account:) } | ||||
|   let(:template) { create(:template, account:, author: user) } | ||||
|   let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user, expire_at: 1.day.ago) } | ||||
|   let(:webhook_url) { create(:webhook_url, account:, events: ['submission.expired']) } | ||||
| 
 | ||||
|   before do | ||||
|     create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, | ||||
|                               value: GenerateCertificate.call.transform_values(&:to_pem)) | ||||
|   end | ||||
| 
 | ||||
|   describe '#perform' do | ||||
|     before do | ||||
|       stub_request(:post, webhook_url.url).to_return(status: 200) | ||||
|     end | ||||
| 
 | ||||
|     it 'sends a webhook request' do | ||||
|       described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, | ||||
|                                   'event_uuid' => SecureRandom.uuid) | ||||
| 
 | ||||
|       expect(WebMock).to have_requested(:post, webhook_url.url).with( | ||||
|         body: { | ||||
|           'event_type' => 'submission.expired', | ||||
|           'timestamp' => /.*/, | ||||
|           'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json) | ||||
|         }, | ||||
|         headers: { | ||||
|           'Content-Type' => 'application/json', | ||||
|           'User-Agent' => 'DocuSeal.com Webhook' | ||||
|         } | ||||
|       ).once | ||||
|     end | ||||
| 
 | ||||
|     it 'sends a webhook request with the secret' do | ||||
|       webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) | ||||
|       described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, | ||||
|                                   'event_uuid' => SecureRandom.uuid) | ||||
| 
 | ||||
|       expect(WebMock).to have_requested(:post, webhook_url.url).with( | ||||
|         body: { | ||||
|           'event_type' => 'submission.expired', | ||||
|           'timestamp' => /.*/, | ||||
|           'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json) | ||||
|         }, | ||||
|         headers: { | ||||
|           'Content-Type' => 'application/json', | ||||
|           'User-Agent' => 'DocuSeal.com Webhook', | ||||
|           'X-Secret-Header' => 'secret_value' | ||||
|         } | ||||
|       ).once | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't send a webhook request if the event is not in the webhook's events" do | ||||
|       webhook_url.update!(events: ['submission.archived']) | ||||
| 
 | ||||
|       described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, | ||||
|                                   'event_uuid' => SecureRandom.uuid) | ||||
| 
 | ||||
|       expect(WebMock).not_to have_requested(:post, webhook_url.url) | ||||
|     end | ||||
| 
 | ||||
|     it 'sends again if the response status is 400 or higher' do | ||||
|       stub_request(:post, webhook_url.url).to_return(status: 401) | ||||
| 
 | ||||
|       event_uuid = SecureRandom.uuid | ||||
| 
 | ||||
|       expect do | ||||
|         described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, | ||||
|                                     'event_uuid' => event_uuid) | ||||
|       end.to change(described_class.jobs, :size).by(1) | ||||
| 
 | ||||
|       expect(WebMock).to have_requested(:post, webhook_url.url).once | ||||
| 
 | ||||
|       args = described_class.jobs.last['args'].first | ||||
| 
 | ||||
|       expect(args['attempt']).to eq(1) | ||||
|       expect(args['last_status']).to eq(401) | ||||
|       expect(args['event_uuid']).to eq(event_uuid) | ||||
|       expect(args['webhook_url_id']).to eq(webhook_url.id) | ||||
|       expect(args['submission_id']).to eq(submission.id) | ||||
|     end | ||||
| 
 | ||||
|     it "doesn't send again if the max attempts is reached" do | ||||
|       stub_request(:post, webhook_url.url).to_return(status: 401) | ||||
| 
 | ||||
|       expect do | ||||
|         described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, | ||||
|                                     'event_uuid' => SecureRandom.uuid, 'attempt' => 21) | ||||
|       end.not_to change(described_class.jobs, :size) | ||||
| 
 | ||||
|       expect(WebMock).to have_requested(:post, webhook_url.url).once | ||||
|     end | ||||
|   end | ||||
| end | ||||
					Loading…
					
					
				
		Reference in new issue