Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Commit

Permalink
Implement webhooks
Browse files Browse the repository at this point in the history
Signed-off-by: Thomas Hipp <[email protected]>
  • Loading branch information
Thomas Hipp committed Jun 2, 2016
1 parent b3565d3 commit 4b4d4c0
Show file tree
Hide file tree
Showing 55 changed files with 1,340 additions and 184 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ gem "redcarpet"
gem "font-awesome-rails"
gem "bootstrap-typeahead-rails"
gem "rails_stdout_logging", "~> 0.0.5", group: [:development, :staging, :production]
gem "typhoeus"

# Used to store application tokens. This is already a Rails depedency. However
# better safe than sorry...
Expand Down
7 changes: 6 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ GEM
execjs
coffee-script-source (1.9.1.1)
columnize (0.9.0)
crack (0.4.2)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crono (0.9.0)
activerecord (~> 4.0)
Expand All @@ -113,6 +113,8 @@ GEM
excon (>= 0.38.0)
json
erubis (2.7.0)
ethon (0.8.1)
ffi (>= 1.3.0)
excon (0.49.0)
execjs (2.2.2)
factory_girl (4.5.0)
Expand Down Expand Up @@ -355,6 +357,8 @@ GEM
polyglot (~> 0.3)
turbolinks (2.5.3)
coffee-rails
typhoeus (1.0.1)
ethon (>= 0.8.0)
tzinfo (1.2.2)
thread_safe (~> 0.1)
uglifier (2.7.2)
Expand Down Expand Up @@ -441,6 +445,7 @@ DEPENDENCIES
thor
timecop
turbolinks
typhoeus
uglifier
vcr
web-console (~> 2.1.3)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v2/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class Api::V2::EventsController < Api::BaseController
# A new notification is coming, register it if valid.
def create
body = JSON.parse(request.body.read)
Portus::RegistryNotification.process!(body, Repository)
Portus::RegistryNotification.process!(body, Repository, Webhook)
head status: :accepted
end
end
18 changes: 18 additions & 0 deletions app/controllers/webhook_deliveries_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# WebhookDeliveriesController manages the updates of webhook deliveries.
class WebhookDeliveriesController < ApplicationController
respond_to :html, :js

after_action :verify_authorized

# PATCH/PUT /namespaces/1/webhooks/1/deliveries/1
def update
namespace = Namespace.find(params[:namespace_id])
webhook = namespace.webhooks.find(params[:webhook_id])
webhook_delivery = webhook.deliveries.find(params[:id])

authorize webhook_delivery

webhook_delivery.retrigger
render template: "webhooks/retrigger", locals: { webhook_delivery: webhook_delivery }
end
end
8 changes: 3 additions & 5 deletions app/controllers/webhook_headers_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# WebhookHeadersController manages the creation/removal of webhook headers.
class WebhookHeadersController < ApplicationController
respond_to :html, :js

before_action :set_namespace
before_action :set_webhook
before_action :set_webhook_header, only: [:destroy]

after_action :verify_authorized

Expand All @@ -23,6 +23,8 @@ def create
# DELETE /namespaces/1/webhooks/1/headers/1
# DELETE /namespaces/1/webhooks/1/headers/1.json
def destroy
@webhook_header = @webhook.headers.find(params[:id])

authorize @webhook_header

@webhook_header.destroy
Expand All @@ -39,10 +41,6 @@ def set_webhook
@webhook = @namespace.webhooks.find(params[:webhook_id])
end

def set_webhook_header
@webhook_header = @webhook.headers.find(params[:id])
end

def webhook_header_params
params.require(:webhook_header).permit(:name, :value)
end
Expand Down
7 changes: 4 additions & 3 deletions app/controllers/webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# WebhooksController manages the creation/removal/update of webhooks.
# Also, it manages their state, i.e. enabled/disabled.
class WebhooksController < ApplicationController
respond_to :html, :js

Expand All @@ -10,9 +12,8 @@ class WebhooksController < ApplicationController
# GET /namespaces/1/webhooks
# GET /namespaces/1/webhooks.json
def index
@webhooks = policy_scope(Webhook)
.where("namespace_id = ?", @namespace.id)
.page(params[:page])
authorize @namespace
@webhooks = policy_scope(Webhook).where(namespace: @namespace).page(params[:page])

respond_with(@namespace, @webhooks)
end
Expand Down
17 changes: 13 additions & 4 deletions app/models/namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,23 @@ class Namespace < ActiveRecord::Base
# namespace, returns two values:
# 1. The namespace where the given repository belongs to.
# 2. The name of the repository itself.
def self.get_from_name(name)
# If a registry is provided, it will query it for the given repository name.
def self.get_from_name(name, registry = nil)
if name.include?("/")
namespace, name = name.split("/", 2)
namespace = Namespace.find_by(name: namespace)
if registry.nil?
namespace = Namespace.find_by(name: namespace)
else
namespace = registry.namespaces.find_by(name: namespace)
end
else
namespace = Namespace.find_by(global: true)
if registry.nil?
namespace = Namespace.find_by(global: true)
else
namespace = Namespace.find_by(registry: registry, global: true)
end
end
[namespace, name]
[namespace, name, registry]
end

# Returns a String containing the cleaned name for this namespace. The
Expand Down
112 changes: 107 additions & 5 deletions app/models/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# password :string(255)
# request_method :integer
# content_type :integer
# enabled :boolean
# enabled :boolean default("0")
# created_at :datetime not null
# updated_at :datetime not null
#
Expand All @@ -18,6 +18,19 @@
# index_webhooks_on_namespace_id (namespace_id)
#

require "base64"
require "typhoeus"
require "securerandom"
require "json"
require "uri"

# A Webhook describes a kind of callback to an endpoint defined by an URL.
# Further parameters are username and password, which are used for basic
# authentication. The parameters request_method and content_type are limitted
# to GET and POST, and application/json and application/x-www-form-urlencoded
# respectively. Webhooks can be enabled or disabled.
# After a webhook has been triggered with the provided parameters, a
# WebhookDelivery object is created.
class Webhook < ActiveRecord::Base
include PublicActivity::Common

Expand All @@ -26,21 +39,110 @@ class Webhook < ActiveRecord::Base

belongs_to :namespace

has_many :deliveries, class_name: "WebhookDelivery"
has_many :headers, class_name: "WebhookHeader"
has_many :deliveries, class_name: "WebhookDelivery", dependent: :destroy
has_many :headers, class_name: "WebhookHeader", dependent: :destroy

validates :url, presence: true, url: true

validates :url, presence: true
# default to http if no protocol has been specified. If unspecified, the URL
# validator will fail.
before_validation do
unless url.nil? || url.blank?
self.url = "http://#{url}" unless url.start_with?("http://") ||
url.start_with?("https://")
end
end

before_destroy :update_activities!

# Handle a push event from the registry. All enabled webhooks of the provided
# namespace are triggered in parallel.
def self.handle_push_event(event)
registry = Registry.find_from_event(event)
return if registry.nil?

namespace, = Namespace.get_from_name(event["target"]["repository"], registry)
return if namespace.nil?

hydra = Typhoeus::Hydra.hydra

namespace.webhooks.each do |webhook|
next unless webhook.enabled

headers = webhook.process_headers

args = {
method: webhook.request_method,
headers: headers,
body: JSON.generate(event),
timeout: 60,
userpwd: webhook.process_auth
}

hydra.queue create_request(webhook, args, headers, event)
end

hydra.run
end

# host returns the host part of the URL. This is useful when wanting a pretty
# representation of a webhook.
def host
_, _, host, = URI.split url
host
end

# process_headers returns a hash containing the webhook's headers.
def process_headers
{ "Content-Type" => content_type }.tap do |result|
headers.each do |header|
result[header.name] = header.value
end
end
end

# process_auth returns a basic auth string if username and password are provided.
def process_auth
return if username.empty? || password.empty?
"#{username}:#{password}"
end

private

# create_request creates and returns a Request object with the provided arguments.
def self.create_request(webhook, args, headers, event)
request = Typhoeus::Request.new(webhook.url, args)

request.on_complete do |response|
# prevent uuid clash
loop do
@uuid = SecureRandom.uuid
break if WebhookDelivery.find_by(webhook_id: webhook.id, uuid: @uuid).nil?
end

WebhookDelivery.create(
webhook_id: webhook.id,
uuid: @uuid,
status: response.response_code,
request_header: headers.to_s,
request_body: JSON.generate(event),
response_header: response.response_headers,
response_body: response.response_body)
end

request
end

# Provide useful parameters for the "timeline" when a webhook has been
# removed.
def update_activities!
_, _, host, = URI.split url
PublicActivity::Activity.where(trackable: self).update_all(
parameters: {
namespace_id: namespace.id,
namespace_name: namespace.clean_name,
webhook_url: url
webhook_url: url,
webhook_host: host
}
)
end
Expand Down
29 changes: 28 additions & 1 deletion app/models/webhook_delivery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# id :integer not null, primary key
# webhook_id :integer
# uuid :string(255)
# status :string(255)
# status :integer
# request_header :text(65535)
# request_body :text(65535)
# response_header :text(65535)
Expand All @@ -19,12 +19,39 @@
# index_webhook_deliveries_on_webhook_id_and_uuid (webhook_id,uuid) UNIQUE
#

# A WebhookDelivery is created once a webhook has been triggered. They hold
# information regarding both the request and the response. Webhook deliveries
# can also be retrigged.
class WebhookDelivery < ActiveRecord::Base
belongs_to :webhook

validates :uuid, uniqueness: { scope: :webhook_id }

# success? returns whether or not the request was successful (status 200).
def success?
status == 200
end

# Retrigger a webhook unconditionally.
def retrigger
args = {
method: webhook.request_method,
headers: webhook.process_headers,
body: JSON.generate(JSON.load(request_body)),
timeout: 60,
userpwd: webhook.process_auth
}

request = Typhoeus::Request.new(webhook.url, args)

request.on_complete do |response|
update_attributes status: response.response_code,
response_header: response.response_headers,
response_body: response.response_body
# update `updated_at` field
touch
end

request.run
end
end
2 changes: 2 additions & 0 deletions app/models/webhook_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# index_webhook_headers_on_webhook_id_and_name (webhook_id,name) UNIQUE
#

# A WebhookHeader is a key value pair, and describes a HTTP header which is
# to be included in a webhook request.
class WebhookHeader < ActiveRecord::Base
belongs_to :webhook

Expand Down
6 changes: 6 additions & 0 deletions app/policies/namespace_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ def push?
namespace.team.contributors.exists?(user.id)
end

def index?
raise Pundit::NotAuthorizedError, "must be logged in" unless user

user.admin? || namespace.team.users.exists?(user.id)
end

alias_method :all?, :push?
alias_method :create?, :push?
alias_method :update?, :push?
Expand Down
Loading

0 comments on commit 4b4d4c0

Please sign in to comment.