Browse Source

Fixes #3372 - Reasoning about Webhooks activity.

Martin Edenhofer 4 years ago
parent
commit
02417225f7

+ 14 - 0
app/assets/javascripts/app/controllers/_application_controller/generic_index.coffee

@@ -2,6 +2,7 @@ class App.ControllerGenericIndex extends App.Controller
   events:
     'click [data-type=edit]': 'edit'
     'click [data-type=new]': 'new'
+    'click [data-type=payload]': 'payload'
     'click [data-type=import]': 'import'
     'click .js-description': 'description'
 
@@ -152,6 +153,12 @@ class App.ControllerGenericIndex extends App.Controller
     else
       @table.update(objects: objects, pagerSelected: @pageData.pagerSelected, pagerTotalCount: @pageData.pagerTotalCount)
 
+    if @pageData.logFacility
+      new App.HttpLog(
+        el: @$('.page-footer')
+        facility: @pageData.logFacility
+      )
+
   edit: (id, e) =>
     e.preventDefault()
     item = App[ @genericObject ].find(id)
@@ -181,6 +188,13 @@ class App.ControllerGenericIndex extends App.Controller
       veryLarge:     @veryLarge
     )
 
+  payload: (e) ->
+    e.preventDefault()
+    new App.WidgetPayloadExample(
+      baseUrl: @payloadExampleUrl
+      container: @el.closest('.content')
+    )
+
   import: (e) ->
     e.preventDefault()
     @importCallback()

+ 61 - 43
app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee

@@ -406,55 +406,73 @@ class App.UiElement.ticket_perform_action
 
     selectionRecipient = columnSelectRecipient.element()
 
-    elementTemplate = 'notification'
     if notificationType is 'webhook'
-      elementTemplate =  'webhook'
+      notificationElement = $( App.view('generic/ticket_perform_action/webhook')(
+        attribute: attribute
+        name: name
+        notificationType: notificationType
+        meta: meta || {}
+      ))
 
-    notificationElement = $( App.view("generic/ticket_perform_action/#{elementTemplate}")(
-      attribute: attribute
-      name: name
-      notificationType: notificationType
-      meta: meta || {}
-    ))
+      notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
 
-    notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
+      webhookSelection = App.UiElement.select.render(
+        name: "#{name}::webhook_id"
+        multiple: false
+        null: false
+        relation: 'Webhook'
+        value: meta.webhook_id
+        translate: false
+      )
 
-    visibilitySelection = App.UiElement.select.render(
-      name: "#{name}::internal"
-      multiple: false
-      null: false
-      options: { true: 'internal', false: 'public' }
-      value: meta.internal || 'false'
-      translate: true
-    )
+      notificationElement.find('.js-webhooks').html(webhookSelection)
 
-    notificationElement.find('.js-internal').html(visibilitySelection)
+    else
+      notificationElement = $( App.view('generic/ticket_perform_action/notification')(
+        attribute: attribute
+        name: name
+        notificationType: notificationType
+        meta: meta || {}
+      ))
 
-    notificationElement.find('.js-body div[contenteditable="true"]').ce(
-      mode: 'richtext'
-      placeholder: 'message'
-      maxlength: messageLength
-    )
-    new App.WidgetPlaceholder(
-      el: notificationElement.find('.js-body div[contenteditable="true"]').parent()
-      objects: [
-        {
-          prefix: 'ticket'
-          object: 'Ticket'
-          display: 'Ticket'
-        },
-        {
-          prefix: 'article'
-          object: 'TicketArticle'
-          display: 'Article'
-        },
-        {
-          prefix: 'user'
-          object: 'User'
-          display: 'Current User'
-        },
-      ]
-    )
+      notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
+
+      visibilitySelection = App.UiElement.select.render(
+        name: "#{name}::internal"
+        multiple: false
+        null: false
+        options: { true: 'internal', false: 'public' }
+        value: meta.internal || 'false'
+        translate: true
+      )
+
+      notificationElement.find('.js-internal').html(visibilitySelection)
+
+      notificationElement.find('.js-body div[contenteditable="true"]').ce(
+        mode: 'richtext'
+        placeholder: 'message'
+        maxlength: messageLength
+      )
+      new App.WidgetPlaceholder(
+        el: notificationElement.find('.js-body div[contenteditable="true"]').parent()
+        objects: [
+          {
+            prefix: 'ticket'
+            object: 'Ticket'
+            display: 'Ticket'
+          },
+          {
+            prefix: 'article'
+            object: 'TicketArticle'
+            display: 'Article'
+          },
+          {
+            prefix: 'user'
+            object: 'User'
+            display: 'Current User'
+          },
+        ]
+      )
 
     elementRow.find('.js-setNotification').html(notificationElement).removeClass('hide')
 

+ 41 - 0
app/assets/javascripts/app/controllers/webhook.coffee

@@ -0,0 +1,41 @@
+class Index extends App.ControllerSubContent
+  requiredPermission: 'admin.webhook'
+  header: 'Webhooks'
+  constructor: ->
+    super
+
+    @genericController = new App.ControllerGenericIndex(
+      el: @el
+      id: @id
+      genericObject: 'Webhook'
+      defaultSortBy: 'name'
+      pageData:
+        home: 'webhooks'
+        object: 'Webhook'
+        objects: 'Webhooks'
+        pagerAjax: true
+        pagerBaseUrl: '#manage/webhook/'
+        pagerSelected: ( @page || 1 )
+        pagerPerPage: 150
+        navupdate: '#webhooks'
+        notes: [
+          'Webhooks are ...'
+        ]
+        buttons: [
+          { name: 'Example Payload', 'data-type': 'payload', class: 'btn' }
+          { name: 'New Webhook', 'data-type': 'new', class: 'btn--success' }
+        ]
+        logFacility: 'webhook'
+      payloadExampleUrl: '/api/v1/webhooks/preview'
+      container: @el.closest('.content')
+      veryLarge: true
+    )
+
+  show: (params) =>
+    for key, value of params
+      if key isnt 'el' && key isnt 'shown' && key isnt 'match'
+        @[key] = value
+
+    @genericController.paginate( @page || 1 )
+
+App.Config.set('Webhook', { prio: 3350, name: 'Webhook', parent: '#manage', target: '#manage/webhook', controller: Index, permission: ['admin.webhook'] }, 'NavBarAdmin')

+ 37 - 0
app/assets/javascripts/app/controllers/widget/payload_example.coffee

@@ -0,0 +1,37 @@
+class App.WidgetPayloadExample extends App.ControllerModal
+  buttonClose: true
+  buttonCancel: true
+  buttonSubmit: false
+  head: 'Example Payload'
+  large: true
+
+  content: =>
+    if !@payloadExample
+      @load()
+      return
+
+    @payloadExample
+
+  load: =>
+    @ajax(
+      id:          'example_payload'
+      type:        'get'
+      url:         @baseUrl
+      processData: false
+      contentType: 'text/plain'
+      dataType:    'text'
+      cache:       false
+      success:     (data, status, xhr) =>
+        @payloadExample = $(App.view('widget/payload_example')(
+          payload: data
+        ))
+
+        @update()
+      error: (data) =>
+        details = data.responseJSON || {}
+        @notify
+          type:    'error'
+          msg:     App.i18n.translateContent(details.error_human || details.error || 'Unable to load example payload!')
+          timeout: 6000
+    )
+

+ 29 - 0
app/assets/javascripts/app/models/webhook.coffee

@@ -0,0 +1,29 @@
+class App.Webhook extends App.Model
+  @configure 'Webhook', 'name', 'endpoint', 'signature_token', 'ssl_verify', 'note', 'active'
+  @extend Spine.Model.Ajax
+  @url: @apiPath + '/webhooks'
+  @configure_attributes = [
+    { name: 'name',             display: 'Name',                      tag: 'input',     type: 'text', limit: 100, null: false },
+    { name: 'endpoint',         display: 'Endpoint',                  tag: 'input',     type: 'text', limit: 300, null: false, placeholder: 'https://target.example.com/webhook' },
+    { name: 'signature_token',  display: 'HMAC SHA1 Signature Token', tag: 'input',     type: 'text', limit: 100, null: true },
+    { name: 'ssl_verify',       display: 'SSL Verify',                tag: 'boolean',   null: true, options: { true: 'yes', false: 'no'  }, default: true },
+    { name: 'note',             display: 'Note',                      tag: 'textarea', note: '', limit: 250, null: true },
+    { name: 'active',           display: 'Active',                    tag: 'active',    default: true },
+    { name: 'updated_at',       display: 'Updated',                   tag: 'datetime',  readonly: 1 },
+  ]
+  @configure_delete = true
+  @configure_clone = true
+  @configure_overview = [
+    'name',
+    'endpoint',
+  ]
+
+  @description = '''
+Webhooks make it easy to send information about events within Zammad to third party systems via HTTP(S).
+
+You can use Webhooks in Zammad to send Ticket, Article and Attachment data whenever a Trigger is performed. Just create and configure your Webhook with an HTTP(S) endpoint and relevant security settings, configure a Trigger to perform it.
+'''
+
+  displayName: ->
+    return @name if !@endpoint
+    "#{@name} (#{@endpoint})"

+ 3 - 1
app/assets/javascripts/app/views/generic/admin/index.jst.eco

@@ -16,4 +16,6 @@
 
 <div class="page-content">
   <div class="table-overview"></div>
-</div>
+</div>
+
+<div class="page-footer"></div>

+ 2 - 20
app/assets/javascripts/app/views/generic/ticket_perform_action/webhook.jst.eco

@@ -1,24 +1,6 @@
 <div class="form-group">
   <div class="formGroup-label">
-    <label><%- @T('Endpoint') %></label>
-  </div>
-  <div class="controls">
-    <input type="url" name="<%= @name %>::endpoint" value="<%= @meta.endpoint %>" class="form-control" style="width: 100%;" placeholder="https://target.example.com/webhook">
-  </div>
-</div>
-<div class="form-group">
-  <div class="formGroup-label">
-    <label><%- @T('%s Signature Token', 'HMAC-SHA1')%></label>
-  </div>
-  <div class="controls">
-    <input type="text" name="<%= @name %>::token" value="<%= @meta.token %>" class="form-control" style="width: 100%;" placeholder="<%- @T('some token') %>">
-  </div>
-</div>
-<div class="form-group">
-  <div class="formGroup-label">
-    <label><%- @T('Verify SSL')%></label>
-  </div>
-  <div class="controls">
-    <input type="checkbox" name="<%= @name %>::verify_ssl" <% if @meta.verify_ssl: %>checked<% end %>>
+    <label><%- @T('Webhooks') %></label>
   </div>
+  <div class="controls js-webhooks"></div>
 </div>

+ 10 - 0
app/assets/javascripts/app/views/widget/payload_example.jst.eco

@@ -0,0 +1,10 @@
+<div>
+<%- @T('Header') %>
+<pre>
+X-Zammad-Trigger:  Name of the Trigger
+X-Zammad-Delivery: 6d600811-06a3-40af-aebd-a2d8213e85aa
+X-Hub-Signature:   sha1=06007ef23c38e435f49091cdfa3c770b3d85d7be
+</pre>
+<%- @T('Body') %>
+<pre><%= @payload %></pre>
+</div>

+ 37 - 0
app/controllers/webhooks_controller.rb

@@ -0,0 +1,37 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class WebhooksController < ApplicationController
+  prepend_before_action { authentication_check && authorize! }
+
+  def preview
+    access_condition = Ticket.access_condition(current_user, 'read')
+
+    ticket = Ticket.where(access_condition).last
+
+    render json:   JSON.pretty_generate({
+                                          ticket:  TriggerWebhookJob::RecordPayload.generate(ticket),
+                                          article: TriggerWebhookJob::RecordPayload.generate(ticket.articles.last),
+                                        }),
+           status: :ok
+  end
+
+  def index
+    model_index_render(Webhook, params)
+  end
+
+  def show
+    model_show_render(Webhook, params)
+  end
+
+  def create
+    model_create_render(Webhook, params)
+  end
+
+  def update
+    model_update_render(Webhook, params)
+  end
+
+  def destroy
+    model_destroy_render(Webhook, params)
+  end
+end

+ 37 - 11
app/jobs/trigger_webhook_job.rb

@@ -27,6 +27,7 @@ class TriggerWebhookJob < ApplicationJob
     @ticket  = ticket
     @article = article
 
+    return if abort?
     return if request.success?
 
     raise TriggerWebhookJob::RequestError
@@ -34,9 +35,42 @@ class TriggerWebhookJob < ApplicationJob
 
   private
 
+  def abort?
+    if webhook_id.blank?
+      log_wrong_trigger_config
+      return true
+    elsif webhook.blank?
+      log_not_existing_webhook
+      return true
+    end
+
+    false
+  end
+
+  def webhook_id
+    @webhook_id ||= trigger.perform.dig('notification.webhook', 'webhook_id')
+  end
+
+  def webhook
+    @webhook ||= begin
+      Webhook.find_by(
+        id:     webhook_id,
+        active: true
+      )
+    end
+  end
+
+  def log_wrong_trigger_config
+    Rails.logger.error "Can't find webhook_id for Trigger '#{trigger.name}' with ID #{trigger.id}"
+  end
+
+  def log_not_existing_webhook
+    Rails.logger.error "Can't find Webhook for ID #{webhook_id} configured in Trigger '#{trigger.name}' with ID #{trigger.id}"
+  end
+
   def request
     UserAgent.post(
-      config['endpoint'],
+      webhook.endpoint,
       payload,
       {
         json:             true,
@@ -45,8 +79,8 @@ class TriggerWebhookJob < ApplicationJob
         read_timeout:     30,
         total_timeout:    60,
         headers:          headers,
-        signature_token:  config['token'],
-        verify_ssl:       verify_ssl?,
+        signature_token:  webhook.signature_token,
+        verify_ssl:       webhook.ssl_verify,
         log:              {
           facility: 'webhook',
         },
@@ -54,14 +88,6 @@ class TriggerWebhookJob < ApplicationJob
     )
   end
 
-  def config
-    @config ||= trigger.perform['notification.webhook']
-  end
-
-  def verify_ssl?
-    config.fetch('verify_ssl', false).present?
-  end
-
   def headers
     {
       'X-Zammad-Trigger'  => trigger.name,

Some files were not shown because too many files changed in this diff