class Webhook::Delivery < ApplicationRecord include Rails.application.routes.url_helpers class ResponseTooLarge < StandardError; end STALE_TRESHOLD = 7.days USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds MAX_RESPONSE_SIZE = 100.kilobytes belongs_to :account, default: -> { webhook.account } 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(batch_size: 500, pause: 0.1) sleep pause until stale.limit(batch_size).delete_all.zero? 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 sanitized_request if headers = request&.dig("headers")&.except("X-Webhook-Signature") { headers: headers } end end def response_summary if response.present? { code: response[:code], error: response[:error] } end end def failed? (errored? || completed?) && !succeeded? end def succeeded? completed? && response[:error].blank? && response[:code].between?(200, 299) end private def perform_request if resolved_ip.nil? { error: :private_uri } else request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } response = http.request(request) do |net_http_response| stream_body_with_limit(net_http_response) end { code: response.code.to_i } end rescue ResponseTooLarge { error: :response_too_large } 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 stream_body_with_limit(response) bytes_read = 0 response.read_body do |chunk| bytes_read += chunk.bytesize raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE end end def resolved_ip return @resolved_ip if defined?(@resolved_ip) @resolved_ip = SsrfProtection.resolve_public_ip(uri.host) end def uri @uri ||= URI(webhook.url) end def http Net::HTTP.new(uri.host, uri.port).tap do |http| http.ipaddr = resolved_ip 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? slack_payload 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 def slack_payload text = event.description_for(nil).to_plain_text url = polymorphic_url(event.eventable, base_url_options.merge(script_name: account.slug)) { text: "#{text} <#{url}|Open in Fizzy>" }.to_json end def base_url_options Rails.application.routes.default_url_options.presence || Rails.application.config.action_mailer.default_url_options end end