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