161 lines
3.9 KiB
Ruby
161 lines
3.9 KiB
Ruby
class Webhook::Delivery < ApplicationRecord
|
|
STALE_TRESHOLD = 7.days
|
|
USER_AGENT = "fizzy/1.0.0 Webhook"
|
|
ENDPOINT_TIMEOUT = 7.seconds
|
|
DNS_RESOLUTION_TIMEOUT = 2
|
|
DISALLOWED_IP_RANGES = [
|
|
# IPv4 mapped to IPv6
|
|
IPAddr.new("::ffff:0:0/96"),
|
|
# Broadcasts
|
|
IPAddr.new("0.0.0.0/8")
|
|
].freeze
|
|
|
|
belongs_to :webhook
|
|
belongs_to :event
|
|
|
|
store :request, coder: JSON
|
|
store :response, coder: JSON
|
|
|
|
enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending
|
|
|
|
scope :ordered, -> { order created_at: :desc, id: :desc }
|
|
scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }
|
|
|
|
after_create_commit :deliver_later
|
|
|
|
def self.cleanup
|
|
stale.delete_all
|
|
end
|
|
|
|
def deliver_later
|
|
Webhook::DeliveryJob.perform_later(self)
|
|
end
|
|
|
|
def deliver
|
|
in_progress!
|
|
|
|
self.request[:headers] = headers
|
|
self.response = perform_request
|
|
self.state = :completed
|
|
save!
|
|
|
|
webhook.delinquency_tracker.record_delivery_of(self)
|
|
rescue
|
|
errored!
|
|
raise
|
|
end
|
|
|
|
def failed?
|
|
(errored? || completed?) && !succeeded?
|
|
end
|
|
|
|
def succeeded?
|
|
completed? && response[:error].blank? && response[:code].between?(200, 299)
|
|
end
|
|
|
|
private
|
|
def perform_request
|
|
if private_uri?
|
|
{ error: :private_uri }
|
|
else
|
|
response = http.request(
|
|
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
|
|
)
|
|
|
|
{ code: response.code.to_i }
|
|
end
|
|
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
|
|
{ error: :dns_lookup_failed }
|
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
|
|
{ error: :connection_timeout }
|
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
|
|
{ error: :destination_unreachable }
|
|
rescue OpenSSL::SSL::SSLError
|
|
{ error: :failed_tls }
|
|
end
|
|
|
|
def private_uri?
|
|
ip_addresses = []
|
|
|
|
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
|
dns.each_address(uri.host) do |ip_address|
|
|
ip_addresses << IPAddr.new(ip_address)
|
|
end
|
|
end
|
|
|
|
ip_addresses.any? do |ip|
|
|
ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
|
|
end
|
|
end
|
|
|
|
def uri
|
|
@uri ||= URI(webhook.url)
|
|
end
|
|
|
|
def http
|
|
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
http.use_ssl = (uri.scheme == "https")
|
|
http.open_timeout = ENDPOINT_TIMEOUT
|
|
http.read_timeout = ENDPOINT_TIMEOUT
|
|
end
|
|
end
|
|
|
|
def headers
|
|
{
|
|
"User-Agent" => USER_AGENT,
|
|
"Content-Type" => content_type,
|
|
"X-Webhook-Signature" => signature,
|
|
"X-Webhook-Timestamp" => event.created_at.utc.iso8601
|
|
}
|
|
end
|
|
|
|
def signature
|
|
OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
|
|
end
|
|
|
|
def content_type
|
|
if webhook.for_campfire?
|
|
"text/html"
|
|
elsif webhook.for_basecamp?
|
|
"application/x-www-form-urlencoded"
|
|
else
|
|
"application/json"
|
|
end
|
|
end
|
|
|
|
def payload
|
|
@payload ||= if webhook.for_basecamp?
|
|
{ content: render_payload(formats: :html) }.to_query
|
|
elsif webhook.for_campfire?
|
|
render_payload(formats: :html)
|
|
elsif webhook.for_slack?
|
|
html = render_payload(formats: :html)
|
|
{ text: convert_html_to_mrkdwn(html) }.to_json
|
|
else
|
|
render_payload(formats: :json)
|
|
end
|
|
end
|
|
|
|
def render_payload(**options)
|
|
webhook.renderer.render(layout: false, template: "webhooks/event", assigns: { event: event }, **options).strip
|
|
end
|
|
|
|
def convert_html_to_mrkdwn(html)
|
|
document = Nokogiri::HTML5(html)
|
|
|
|
document.css("a").each do |a|
|
|
a.replace("<#{a["href"].strip}|#{a.text}>") if a["href"].present?
|
|
end
|
|
|
|
document.css("b").each do |b|
|
|
b.replace("*#{b.text}*")
|
|
end
|
|
|
|
document.css("i").each do |i|
|
|
i.replace("_#{i.text}_")
|
|
end
|
|
|
|
document.text
|
|
end
|
|
end
|