From a515ea3b1ff032f4a684b18637284393dba8d354 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Sep 2025 13:32:42 +0200 Subject: [PATCH 01/31] Create webhooks --- app/jobs/webhook/delivery_job.rb | 8 + app/models/event.rb | 2 +- app/models/event/webhook_deliverable.rb | 7 + app/models/webhook.rb | 25 +++ app/models/webhook/delivery.rb | 118 ++++++++++++++ config/credentials/beta.yml.enc | 2 +- config/credentials/development.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/credentials/staging.yml.enc | 2 +- config/credentials/test.yml.enc | 2 +- config/environments/test.rb | 3 + config/queue.yml | 2 +- db/migrate/20250909113219_create_webhooks.rb | 12 ++ ...0250909115638_create_webhook_deliveries.rb | 13 ++ db/schema.rb | 24 ++- db/schema_cache.yml | 147 +++++++++++++++--- test/fixtures/webhook/deliveries.yml | 41 +++++ test/fixtures/webhooks.yml | 11 ++ test/models/webhook/delivery_test.rb | 108 +++++++++++++ test/models/webhook_test.rb | 46 ++++++ 20 files changed, 551 insertions(+), 26 deletions(-) create mode 100644 app/jobs/webhook/delivery_job.rb create mode 100644 app/models/event/webhook_deliverable.rb create mode 100644 app/models/webhook.rb create mode 100644 app/models/webhook/delivery.rb create mode 100644 db/migrate/20250909113219_create_webhooks.rb create mode 100644 db/migrate/20250909115638_create_webhook_deliveries.rb create mode 100644 test/fixtures/webhook/deliveries.yml create mode 100644 test/fixtures/webhooks.yml create mode 100644 test/models/webhook/delivery_test.rb create mode 100644 test/models/webhook_test.rb diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb new file mode 100644 index 000000000..4ec08b900 --- /dev/null +++ b/app/jobs/webhook/delivery_job.rb @@ -0,0 +1,8 @@ +class Webhook::DeliveryJob < ApplicationJob + queue_as :webhooks + limits_concurrency to: 1, key: ->(delivery) { delivery.webhook_id } + + def perform(delivery) + delivery.deliver + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 811e207d3..5b2ed2969 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,5 +1,5 @@ class Event < ApplicationRecord - include Notifiable, Particulars, Promptable + include Notifiable, Particulars, Promptable, WebhookDeliverable belongs_to :collection belongs_to :creator, class_name: "User" diff --git a/app/models/event/webhook_deliverable.rb b/app/models/event/webhook_deliverable.rb new file mode 100644 index 000000000..1666390bd --- /dev/null +++ b/app/models/event/webhook_deliverable.rb @@ -0,0 +1,7 @@ +module Event::WebhookDeliverable + extend ActiveSupport::Concern + + included do + has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all + end +end diff --git a/app/models/webhook.rb b/app/models/webhook.rb new file mode 100644 index 000000000..6878b62b3 --- /dev/null +++ b/app/models/webhook.rb @@ -0,0 +1,25 @@ +class Webhook < ApplicationRecord + PERMITTED_SCHEMES = %w[ http https ].freeze + + encrypts :signing_secret + has_secure_token :signing_secret + + has_many :deliveries, dependent: :delete_all + + validate :validate_url + + def deactivate + update_columns active: false + end + + private + def validate_url + uri = URI.parse(url.presence) + + if PERMITTED_SCHEMES.exclude?(uri.scheme) + errors.add :url, "must use #{PERMITTED_SCHEMES.to_choice_sentence}" + end + rescue URI::InvalidURIError + errors.add :url, "not a URL" + end +end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb new file mode 100644 index 000000000..5e3252dea --- /dev/null +++ b/app/models/webhook/delivery.rb @@ -0,0 +1,118 @@ +class Webhook::Delivery < ApplicationRecord + USER_AGENT = "fizzy/1.0.0 Webhook" + ENDPOINT_TIMEOUT = 7.seconds + DNS_RESOLUTION_TIMEOUT = 2 + PRIVATE_IP_RANGES = [ + # Loopback + IPAddr.new("127.0.0.0/8"), + IPAddr.new("::1/128"), + # IPv4 mapped to IPv6 + IPAddr.new("::ffff:0:0/96"), + # RFC1918 - local network IP addresses + IPAddr.new("10.0.0.0/8"), + IPAddr.new("172.16.0.0/12"), + IPAddr.new("192.168.0.0/16"), + # Link-local (DHCP and router stuff) + IPAddr.new("169.254.0.0/16"), + IPAddr.new("fe80::/10"), + # ULA + IPAddr.new("fc00::/7") + ].freeze + + belongs_to :webhook + belongs_to :event + + store :request, coder: JSON + store :response, coder: JSON + + encrypts :request, :response + + enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending + + def deliver_later + Webhook::DeliveryJob.perform_later(self) + end + + def deliver + in_progress! + + self.request[:headers] = headers + self.response = perform_request + + completed! + save! + rescue + errored! + raise + 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, headers: response.to_hash } + 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_address| + PRIVATE_IP_RANGES.any? do |private_ip_range| + private_ip_range.include?(ip_address) + end + 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" => "application/json", + "X-Webhook-Signature" => signature, + "X-Webhook-Timestamp" => Time.current.utc.iso8601 + } + end + + def signature + OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload) + end + + def payload + { test: :test }.to_json + end +end diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index 21bf6f859..bab48ab9d 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -hTAo532D++IfAePtvgvwjKd6Cx3RRCdoO+77b4yFvgZSGtEXB6mfNGKlEICRL4o0qh0lqimE2/3Sbmnx97lzhfdYXE3b09KewXIub14s1jzTRWzZSRPNcxPeYou10kiN0b/4R/IFON8+/P7Ratok1MHIrAt4M3EUYwtEpzsi3aOYDGlRoLW5rAxFAHRl7zhgkMH3Kzkmjh2tr+QOc5fxqmYT8ETmLJrOtvJtULMW4mfie722zmwm9RFJxtHpz4gnMAwSr2vaZ0edk0sHeErnDJo7Zl1Ul7uCJyXPP4PCqmuNVOQbxjLMNVTmo8ntUWZa7pvocpa5uYy8DIy3iCAYYIxdh/xhJPnqPC0XQnS6Aj4zaOD/fOXSnEuSQjR8d5XyIUAWuywh6Li7qWyj3W8btfmc1HWOo4cwx8LkwRuUG1r/SvMQdIR1E2xtgROaoXZmPOXmYlFJm3mzy+oGOk7Pa/u/1iiBiNEkDx4nhHe+0XuPZcBe4c9PKn3zw/3SOm32SpBqe6zPZI/68VoiXx5wVaogY5gnKQOHNkhi9RhN6SSrM58oS+EKYkCJ4+iRqj80IQNtj4in4pyQ/ONZgc0tjMh1+C342NkhbaJYmGGUPpPVhZPCUf3clbBXqex15+6UkmymWtz3WH6GixPVwrvp1gGUqOmqCiWVNJM/kQ84IOTzlxfeyKjGK5xKhEC3ajYqXDDUDek/tUIhqtOYxBS9T44cKDPDTL1BKFMdIDR7gdtRyQouW3RvD4juqyjh710I9aq3JB1Jvj8UZ7uSHYEJ9NpmllfAx0RrrxMFHcwr3LqjDlDf7mtACfDISVtD6TQLuGySDIhs8gWTiSuZ75jRTswx8y3paI17Yy0en23lDIQ9hlPh6xvvJWQQtCT6B8GCz56GrLOVDfKM/0qeqJtyed2p2/MIJfy+QmqTSMSeQz+wiLi14I2Vgy229A1CW4izMSoR+RqoZzIzCbZg29croJv9DRhp/nVtkjj/enHnHksZKqoxzf0c7nHSWtGSYtCiewDydsBCioKdo9tn/FM9UKpnRAy1XHiq1DvBQ+wuq74KLnC/4nBZ7P90xlI8jQuQfldpDRqKaIYjIvz8UbLdKOjETT1dJjZ9Tk629zdLEVybLSGhoE0bs460oFvlrjJDCXtgi/3tWbTIuwsxY6Iy3eMiS5Kxl42dnlOdVrxwwY/om/MzL4+yvsx38wXS2QG8lDKz2JuGBS4GYq5EEg==--P2KPkoocz8wNhqSN--1u5WuEBtEaqJckCflHkb6g== \ No newline at end of file +fYKvBhjXnt2n6LuJUgfMQVDoMcmxB7eSCHv97u4HtXcii1JFEvlFXBMnjz4+4QPcXMlp324db/bfCwSEed+iA9/TTt1HoYYuxypnbiaMnr2HTZVzECLWrlnOvuw9IUIYkSyQ4biW5Tq4VeU1GKpHya5Giop0R3SQKcONMrDhpBcUJ2TepCFYjCp+U+SvsRfUiMnyWk/8e5P+16qSk/s3AnlnlCbX/8kqX3HF0zrXW/tcdVIAvYrMKSzcyixH2YS6G9BCe5cvvL3e3FTnImxscc/cVecXHDDID00yG2brWpgetJamhHpfLmbiPgCpAtz1vtAEDZRJHtXw88AkCJ8wvKIZB+ZMZV3uJaxZkjx1WDDfZUP+Tw43Hq7SrxwGyCmyC8hVAJez1fQh8SoT8tGJcR30UKBxa339K0slXenLsrYc5eVbwSaxEbqgIjJWDdmY9vZ7fhO397WSBaF2tK0aBU36uRy2XMeCmLZX2lnA+gLFSt1D3XMw/sVnEHdYxuFMWg1ScnMlh0nfMMVjpSRXhcd97I/fWnJ/iWcNM+CBMvjCnjVy/6T4ushPO2lfiFAcQAqURDkuKFVpDrQ19V+aWx7uppQrGMvO8GqX+eTR2qoSdk/nnB9f5DsORVx8IBLkCz4p4JZMJiQTOTcp7HhxJf6TEqjCD9E4FwRqZG0VL7o3ZiHrls5yEV4JvNJX1tQZMimttdHP1yUhuR8wtAAQUziNeERq5vuKYhWMOVC8sxnaCksIqfwT6SX6MuwviuwdlREcd/WL4u651dusI1PLdl18n2V4opEJcqr/z4GXHnz2+X+PXTdLGrWn9YdIW0Crm8OVhGHQF/K/zg4FUNPhI6JJm/8Rw4gTdDjniF0C0DPOKZW/7Rrk68g9tn1zB6USDjXcHcxE5rg9YFHXGCnrV2Jzd4JF3M+VLgA4wzYk9IjU6PVqtjWpwRXy09EvbspsLgSYDH4LYb3nCkj/CCYTOnI0l/6ofS63kjyNYG/YD5zKpiRcgU18qiQo1uoIS1QijAt3PMggpIbl9gkcgdbed9qd8vptWNHCFKtNmFo2FKJxsLHg+4OzSotl5PYnYzisFFQyMTKuX541fIMtynD2IgazHmE3p2NdUQKGsZXrXrD35EpDCTiHNQf6Emz30xZ2md7R0NZ4043wezZ6w8hzQSPpVPIrCsrcR5UnfO6KoBeC+iMDUS9+QKim+o7OIjggeE8yDS8lsHASRX05+uUGzz3wId6tvUtgJrXvZbGdFNWCfthvcTeb0m8zGRhHBG3aic3GQ6Hwl5TyXH1lo6v66mJWwPL3YhiJu4kyowLz/2DKiMdvuGAqkqCgokpONzraDum3Mid2N/5B/z0xNnaTpqwWW7eyGc7c/1seNZUgA7Maz7eb1wMv72Xl3VJcxoFYANbCKBcRpFL8ctwAhE3QLojqb4EKDhI31wXynu/i5VVU2kNedaBT8rGhoWvZjrd4v4PNzKy65PJxKkBkQKeeXMJMOmVm0BY4QyCOZW0B/H0z867uB9CjA7Xcz7Wl/5dflawlV1mpr6EQECZd+h8v5mGFeOs0zS87q0xQVfhAcTqYGZKjSIrZZdvppKW1PXc8rNFV/3HbawnPeot4RoUx17bnJmZsdbjYtpq8tAvCH5WRdXsJFt4NvPFdMB9fRmVOZs/XoAtyjie9PJ8cZqjkDk0yRPcB0vOqYLVLuoVm6TrRPtlKTAb4uLj0mM4e--srcdud6UpP4PxVue--MRrzzScZrcPeG9j5CpQt3A== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index fbd189683..b5d652eed 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -qGtDqUHZuqVpajiHSIgxtaKdNgBjWFZaKMWitxGBAtV/q/P0uVAzry+MRonSMglb8SZEW28/LAKF28kUeAMGZVwnbMHAmcmOdOHNwfPBDu/3+c67J0qedEH2k8998o7wBqDi7iFTaWlDQROOi8LiiE3oVnpsRZGXtjXgClYI5ED062ITCd/tMLDsXiZjq/PhodWjI/YgEycwy0r1OldJZjOCzS1VD924AfOIH2/qtszc7oeArEpv8XYm3DJDCixYy5LLnhNIek7o8CHaZIeQxfm9pEh5ZEXaMNfSB4Gq+laAyLoXqsoPWuonjw0273Rjw1U3PU+6GlU85ZUrm9dBnekZiatLIHt3yX1CKynS8yqen0ON+MrP5W98pAa9Njq2GtB2SUrGY1xQtyoIfYPxFbNZoOph4PubcXqCT94bkAbZOiYKSiNLONuhhvKR6r9amh+LlDELp7QLR8EIfRpgoN5D8qhbhEi1/c2Sxogv/7+YtkpaYq47Ram2THll+URQ3L4iLKyPIaimxt9vsqdO5COn69fTqtqkYR/fKy93/3JIJxiNQ8I6z5xhGRLFk9ADWB3ZVz1VzXzW0Q1B0UQmMg0KEEM9ZQTxignWeJasbG2L/T656Lg/wPdKdAw0jcZAOh+BGtePgOOjZ5Le8rweSHKou0kTn1EjR81csRuSnHEEGTdlNp34LC0NirqwEwmPySRrQxmNwslS82R8uQvZQ+XtalJBBvUVwaB+o2rm9AfZFzzjGmZhVOKCQLZx4ugG+CUfc+soLGuQtcyPQe+0V2hJ97zzqdWxKvdUQ/aCerBwUQczl7r/PPxKucl4LtF+3mNYNMWjIdNIHJRzhvCnmZ8Zj1TuO4SGWMKNx7kwNP54dd4pqRD6Z0vm/x8zZXeccfu8Y39IOlawyEDR7idd0CSZ--41mPjeBDVAaVR2Q8--Qfq5LzxnQOYGDaGie2K6FQ== \ No newline at end of file +DaJ7nO7vhM/zZdRzh0AeG3bZBvfMp38E70+jOoUG/oLykxrem/qoKauYuTuQXadi0EzN09SYeWmPeA/p05PcNNOsw1IvpoQnrUqt3Jl34BKpW+3dcuVAafEiFR5Cvg8XOQu6Wu7KBklw8ko+TffCFkF/dqQ+60/snEGGqC3PqavHfmfbvwJyiXHt8/R5kfraUUd4hm3Rf1TikNVpnWTFOG3eMO4tcml4bmjgJAu4wGtAyLfosG/OaqQba4QRcXsF7HnH5yYcb04aEdFSmbSFIvm2OuGvc38KTXAsaFCZnvW3kIa/mMCyh5AGaE4srT9bmF/wti8CzqjpRDHRAEf4rqfYYTV+dRwBn1115rbEdQx1GDAwiuU/g9jqf9VhiPfaNRmcItnZflejWfi3LiPNFTcN7fSB3r1h4gg13dUb5C8wCRFc31cWNpERbzxRaneVP2iTQNDIuFcraf6aiEVAzWvGwv6SyLpuuWLL1sin3O2IrdZQRnO/VzaKnTxtqMVAFq+rxIA/511fK2+Gh970JUMXaJQZduiTv9aBar2q/raq7pvalrBqAWI9NgL+i9sO7Tf2stgti3W5FIsMUDQu54DzMpq5+mL6iEI37Peb4/jsFwDPicsM1mLRtXl3PiwZeXmURLUur2XdKaZB7SEhTYOhshIDKLR3Szn4d8INoZpN1RdHI+RikLISqMPlV09T32+I8sNRZ2V6+0ddqidtpyjUtsFrLNgo2gVBCepGBjrV16QlIpaJJBLzIZeq71GHJ8mc5zyYeJR95vgAecVEnMVmjNk1H1cL8C0lQFIlt+OsRAkokq5sF32OB/2YyDTFOHUVCvTB4P7VRHO45nU30hkCVGNha0yo5iyuCh4fFAjJbKh28BWyJ53F8kMNwwlsau/KSF2hJrcn4eGL7r2CvI6R/kLOJkVryEHHUb8xmc8HJhLKoscrCk26v0/LnYHH0Vui+3wmFsC9uATgVqURsuvzvzQkaX+qClec+F5YVJ52MUWF/b7BGCusWnQ9A5W2Jb/bdxHqKgPG4LaC8sdDwbgOCUWCTaOrEAuCfQUK+Nv7aH72BmdTLkMliOSnJWOZLz/Uubzagdy9sAuuqctFg3M3QTPi9Hgua2fch7XgGv+P+P8WF2AppwDIuEhKpzDPiJvtvKI1doOTtw==--LL+6EQr1zbYQIsEa--B0if+ehjZyS9BYxbPIbYKQ== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index 0720c2059..cb2630e38 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -gJofgOA5kUBwjcFwc6IDRjLbYQfG7qGpm8BcAEnS0FODuMbEE48FKN4jdUfhGAw0yCHu9DT1cJiP2wKR8s7nyf6dUGl6fPljR0sKTvO0eznmWigWCc+Ki6zVef0mRqQ2lZguiokjapG9c4qBcy2MYhVOhw3JAYvH6X4piiyO52nLL/kKLEQ64/uqC9VDKgR8TRhyVRqQN2jvB62VnIGxWX/qSPcapF/jw8ECgPsXgOSY/s4oNvQ8gCuZMweDlSYqFLAXpn2GP+wAaT9u6UmprPx7niDb1EIIm+LFWU1Ar4lY2n5Mo9ckN0ddsoMnwlsYTjlAWnIn9m0o/GbaxWmtSScnxHXbzqr630rEffvZrzLTG60dxkI0it8cVXwSHI6pMQHZw/lUkHd13IHMzR9eNGUq4pV+fdQjhvcfS2sTwVeCZ6VV1DbnY1vOeyMnVfvFLlJ7eqChkpFBAmOKS4hV/Mrqks6OP7K6ubyXt/REwuNhRMeJ1SJAfgiDSKbmNFtSnODyXKP1nFTCvvdndiP1H6t8qkJo8COSnSWWzcyyVKMwEKwbOxxtX2C+V4aySTllw0gFODLjQPxPBwqnkwcEcDEHxtsgImOYnOc0Ro/4faI1C5WdCuF6+VOBGpq1nI4HCQqepx4b/HZt/EyJSaQU08emhHht2vlCqJUN2yPUxnbCKBAbQhz0khRANBWIf8q0wfwSLaAqQHJ9FsZp4ztOC5U4lULzaMJh7dBUbv/9+mH6Yb3lFbg57178GYCgbzFwhvEOlEKBjfwDRFNmYfiVH4YYbZTIgQvu3tSywNNQJ4flu6TSjMC6zdOBi41BAIXH7j04zwpQV7Zndlu3xE1LTMbcLfBdqzOBGuRVD5KvQH5XFj0RfKTg2DN7Mo2XwSb7IZnl3Ga/Tuvb7DwKpRtql2RCYIYXwg5EnCYF3rw8J9m4tLzr9AFh3KALq+Lnfa0d1MysB1NQJ5XqtJ3jeYW+sxv4ivv9gpzQ9SZF26Z3rZoQHnb2S7usIgzEquiDyhAQr67AYOMJe5DKRw/QShJwYK+k+n/IrpQxUBnTrZvf2I47KF6UkhgWpZgmQaDB7WR1PePHVqJdkdQgqQzUU2t0SQIUY0FHH6MRLyHDcqOFiPl76HNFOmElZzrfXzqtwQdXOau7hV645LqaUy2xinA97zkEdpAGr/P/eloveh6wIgSIZNTsm8RbgRkl8SS1O9Q9goxMir4S3JvjWTJmAhxKaKlGeIVcMRVIv5C634Ugpp5jzh8PHX9nHAN2w9HHI+8AMaBUeFyuixbfk88EIvby72wY8MWxYzv1H/THWSL8WTiQJGxWE4PjSBQtwuc1zzHqG8Ve45Z1nIkt4TmC1VXftUHj5SghncAHCA27v+Hdk+cjSw/ymaVwIRreaZYMjOs2xX8XGOYXpbChFJaMk7ywR3FyBzOVvFXGnGCuA55f+j/7E2KuBI4lnr/mmCTeYvLOBrBDd2c1sEjKTURM4cSlDBw9NKc8oloxYZQKn9vA--z6fcV0Ojv6v39SCA--yOvnjjoQdp6k7P89GMEgYw== \ No newline at end of file +mD7x3X/gvld0L+2qVY7VrjA10FcuFGSXkYW6Kw2gvx2MP9rwmTZduAnTrE4X6q8CQKVJA7kZhHj2Kkng1ukrJzDrSionpAp+YEQ+7dzpbO/nBfjyLDnPN17rOayrN7N79mQVwGHI57XAGT+mVUyZetxzaZMroPwPpt+1vtJnmLbTikMK7qqPfwJL8sqzoVPG2GE9qwB7x4Zo9wGtE5TyCHYUfNmz8wNlfgDKxtqajJsm8wTb+8/OSd5wVwCdulGtEYh6TGvrOgjCK9qn22hhtl8NZcAbhTBKr2tunkXirYRkPw9XDwvIGDAVL4ZwUZ7A7cRMGJ8Wyb/C7LHfTo49+Lj1EdRdBcyyz+DCpmXt7115JBClNS4vBeQ/t9KL9SepySeG38dsHfjJtZnu4bTP4IeT7S1Yqu/3e46Tqg6/2q8nPrS+FcSvLUy3baKi0RrgvBdE9JR5GCDmS3LCIu64f04cEzhFh735iyZrrsfyBVhOEh0hZIGc9xKvByDnnIH0pz/c+8l5LlyW8gqzRb7y1pg9W8qF1QK8kb7/UyiHovVWoXYwKZPNVy9qaDYUYqcXSpnopV85iTPWR49XJO2Q/TkX0vEgzOe9/REpRSbcvFCPkvh4rCScuqMlttVvnJF6JBHSbuXAkJL+yWZeqzc5LYQ8Os4i1VTPM2MIavw5ldU8I8knNY2wUz3dVbz9accJwf1v7fgSU3tBBRqsbuvpGIiUrJtnvCNWvtUYXBHgEcEabnAXTHO7Mg6pwdI9hSLlz9NeBefqmBb6xnv6bfx9C8P2dTdHCSaDWhh95fd/5hJBMCeGhnuZ7O2NrMYP0uUJfzdDj5JSY2p2+Tvw7iUeljh/Tua1g7yhLICTKkuu2g3G4vMcO7jcF9j5ibCbHgcGTUm9oghPbAINfg8JpdD0tA8aTQdLUdq5ue91PL/TUi3GgwzfCwUfxZFz1HjudVZyTQuIfuVWnap5r9CcEW4QjTDOreYNCqYd58XYj/Pm9LPiK9LcfYarbmPobUiUg63IW5jXfttCaAWToDWc3YuNU8NLTd3axXkF4NkS/GBRk18NqMQjbwXYR8eQctbQIEhnqANTkUwVp6qO3M//6nBTf3dp/dS18EmFgyGRm6cGYAsOyBNXyM1UCX6h9uG3scZPxACffEViZ0dNVJkOySVFFMEnSpY04CmVB7RtbAnBxleP//2bvJucffRVMFm8y42JzxCTC1qao2Es/IIj9epSK3l+YIGcW27QTr4x0I0swG5cOLxAQ2/HUdwZmCk1Oqjrtn4prHC/gl5+LIzsVmQ2dfcbZ5dyM/6IRyJNMQlknLv9FerCtURIzb/O0Lx9EfrQH18KP9KFPhgCMLsa0kt3JdYFfoAhFBrsB0Ioc4+F279F1j5+X5M+zCXkYq/71BndgCALwC7oVCXWFnl95jYA6yDTHMIr8fRjqV744wU9ZIIZPlaf2+duox9REOQBSnj+olkIHfh1AYHzFs7Sa9z3d2jBoplwzVqXbPjd4u882BinLjPy3UYPPnXtx7ND4MgsP5/NNY29OXUChr5OL658ayJADTdouXgIOghmfEKOacIn3zCiJbSJFH29jgS+MGIA5l6OxHkzj787MR7vC3mjF6Ut0f97LYVEMUshh92DhHao/oM4lcvdoQmhO5OI0zfhvA5vXhTvC2BI+CQjgRKrjLkCpEU8ZvHQ8ceO5hzpx/vf9v5tJVX8Q1EOZ7QklD4p4I9OVRWX3g7Sz4iUP5edwG3iVF7hAw==--mvpDxs3LkB232zsJ--tC39AAOxnHH12G7pzQ0nWw== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc index b9f413f86..47b2e3af6 100644 --- a/config/credentials/staging.yml.enc +++ b/config/credentials/staging.yml.enc @@ -1 +1 @@ -DHJSW4c/xFkMWWYW3b1uqnoiwuqVvRabPVvSKZWaoM+EPzUses5EaJ7GBXjpVS/4TYQ+0XsuLBTniuhUtFx56PHo1vWWFRy3n8I1YGPOs2X9mvG2wK5MlwSGO55xatQ1MabRyfjVJfW/inx4oWaqh4IsZhQ5uL/PUWwWy+7tKx3yWbPDa9K2vsgJuJJqZav0gJ7Bn5j8sdpj8xRofA0ZmiA3QxVIHOLiBYRXH9gmuzJ9BcDiAfZ1EA2FnM7T/2v5u6K4k5Lkp4Khn7t5kSF5hrUIuU7dWFaz4WFS6sfcCkRTHgPKu1ZfunzQl9vDXxkwVUjnfGnIk6XUQz8qfI1sDwhxjaiKAtn/6V8/zSo0v9lj91kfVWg/e8byzDxtZhRN71lnDgwaRIQxSDumfFIX2Yd5aXoQw7b+VymZaA+8J7vh18GPkBSsWTZ9b1fA1iVHncSDSEYH/FVXK3Ul/mpcN1bJrlBXMkZ0HmDA1EXhMJqPGosE6/OT5f1Zsn5SSpbqM6VZjQ6LOHwNCZs24F8BhGnCQtdl/VlIHKyGq8/zcyWUuROAVSxV9fiMDddWrprjDsjXPUigFqgtyco1CCZ9p6ScMihOzpOhT0dcvvjKZY56e3GR6n7u5JfSL9CpykQOXATHBXJlAc3HYxPxfESkEa31VmwZGOZxUsTpIZDLC/7VCHXGgtvOYLfoPBJBZ7BP8Eun+q/hz4iAr40xlfSeg3G18JpQZYHeF2/3WucrTW8NinwkPW/Yr/jtd9duyuIZNY2/zGyLiyoKiuHnk/iVDsyLIth1P7ZoQNyyiITzB/OQ2KqAvqwjnNYqLfBAQfe6grBdAGrNInsBHc7HNMcuHP9bvvS2cjlepP4zZyKo5oiAkmaWUPuBkUqmY6ZcOmwLTwl//fBXlUzCuUhR+0k7af6IlCsDhmp0HL575JmYN+HKGZ2LtQo+1v4MFJR5c2gh0Q4zTAkJsaOz+39aI5e2VPXkpXNyz+VVFJO36lg9tpaomUgtI2BjrO3Y3qQ8Y5jilnSX2HslL4lFMLDvyphDYJx7ODPimUjNkJ616YXSgFJSBUVa/ui1CnxNqOfO+AtuxJz26cuQw8ORKZNihd0QT276pHGhnXb+B0JT+XCuniZnSF+oCn6Kmbt1bsElCN/sMrII7UdsRGD5sQWicjpOM0g50GPYF9z6v8tCYlEmTQSjp7/KOwKLo4l4re3KeuD4FuK/1n0x2CNm1MLbvXcOGkmCa3P79YsKl8L1x8+hbNoHpEpswc0dPb6FLthCxCqrGQiREO4pbTrLasVL6CJA0eRd39B/8AcXnsJwNj4B3a2PbafnkdhJMPAuXvieFFN6leMXBF0YCsg+1Br8h9YmZfzmkECo9JVkNCfYdNbkfo8AA+hHu/youi6vcJD+x+eKTVdi88jy9qwyDdzIf8GdiIHy00PnfXj4lgTx7SQr+WUpsUYnQZh8xqkAuh5Lgi7ExqZMjjo4FORFxoJHToNU6c44+aVb36P9Fg6v3YedINYZkRWMTUq/f9O1zoOx3wIE10Qk4SkF+MLPSuLJWbEmfCyNZZwFUH7xde9lSDfB1s+1XhtEz0Li3Kq8jCeftRcIZvQ3LVjFuirf6nfZmZoTjaVDyUiWHtXyrQ==--NSFlvZtAfCQmzAp3--o8XatBC+Yh/p55fY485A7g== \ No newline at end of file +hIHE2AaAwWrP9YzliIwVuq7iSyy81LNMmeScQYahZqbNW5pgdnprKwMVwp/R15mZrI/tNZI1utxC1FFX4BTsRgkYhVGrQDx74bB3EaT4JfEpPARpqMLYWMWqQ/xzuvhknoweR17Bty/E9gMgdw1Ip7pmQpZJRQVntSZIMxr+IysR7cbXm5Em9+LAxhm4ku7Zu5sviW/ljzOH/vJ7WvZzg7E7SpHNYV1cALbIjKvQKcBppR4tR0cXcUZ+7K9f5Qxp84XVWi5CA0IxLZ07Wq5QAPXN5/Cc8PkNZB9y7aRQ6lfaB+FzSHpE51I4u+zp9j5qDWU8+vAj5vskbDWn6t2KLWHPJRydhSo9u4Irh/VuzctL5JpSSfOJqmA/oCFnEEsrafcL+VMTUmTODLAnhMB31zOJvny57WN5oClFp/TdEoyvJy0bKkkMPHItNVoESKgyzdAmK57UyL19kyyuaJUw+qnL5CCOzSKDq6jd8ImH7AEGVOqrDEsVNQvBCPEh0qlxu1XY2FdsNdgSM86w6aLafX3DcWuawLg1CjzZDcc4GKCp36PjNqeTjy82biQ85kF5yXbLv/KjY4gO363jV9tnu15tQOc9BLaR12lbIerdUTvPQ2j1n/TReWIbmyUyZA/yoH1R0lz4XIl5yFe+jZ6AyyJ4SN4V/XNe8N3ejqKghuTuujfn/Eo5GxzMmGrgUn4GNtY1Z1p8vjAVb3Ime5gDDp1pvOqyPUlqotjRpn0V3aYbWoa1y85j+geQimNyAZT0lkbd4f4ExdsNgVmgd4iPCLiUuAfdH5b0w7IC4c8KgPfox91bKOQa86GytG7ch1h5+vyL/1fk4oXn/fj1vcszF+xzvYxmbZhh9DoYrxbZSzfjCZNK2I5DLeDZioWTO/HVVoUx1Gpp7hKR4iJxa7793m9BfsNz+2agdi8N62WNiXGM8hW1jPVKNnAD6PyMx4NlSe6vwyVBQA3ziBu0PxT7NCH43zqG2VpuQyV3GAmcSqGFDvbiqub9eIQOvBTylSE5KPEBTIdUF/JIe/mysKaMuaph3gOp8YT91xQ4DaT3DWYyjuRXhItWi5nPUbF5WGSXHPAAhXD/Qp0YNiDzClfIjinpqUF51FhfACCuMdwmKAf9almMdxcXKHvwHLpFJbepHMeuPMlbojNEvfwLUJi2IQ3YFQy5qLbu84aPTW9oSYZEJ0Rx2gOSB1wv3FNtcSAjDiq+plQQs4O1MxtmlcwOeHhwXGSVnnEbjabXJ92JI4yhReIDtEzXrSIaNu4HZtDdzQwe6KwyXoR2dxxcrhUXe2ImjxGLlhnaygdvr5DVQx8XfcOFCIHcSlxCXND+jg9EdBS9m9wW7PpG60+40BB1hjRkoTrzdW9R73m1E22CKOVlTzB8Am9rk1fmmxDyA8/SsErDo9J8LSmzCyQVomjgPfVtAXjcaME53UKL/Q92DLlJZv7koj7bSpN7XSH+ri50BrMCIURJuoivBgu6c8g51OcDMJCUYVajbws9W8hiA4t7dhRsC+1jh+0T/qfu4Kelo/RC5rLMc6de+Mmw0E3HIpTEBttJJJyiIpT0UDzoGcDpq+4Fa+zPRbxIPcD1NnfuhR/og6KsWvLeIsUfKDD+TI/gSE2zxYfC+/XaZO+8TRNc99Ep3qNJ5ZIy1hitn/wMUvCpuMUZAxjOlfh6CZekYgOFESkGKL+b0/O8xeqV7x8qE6SW7pTjCOoykD/jB4XNXY/M2RA4sEIgge0W8v4ENzafDsIFt1eQI/T8vGnbcUxvt7TaJdQ6hwanDKZ8kdmBrO2QG2+Y2vVhoxNYm06gjNAzygnjpYXyUtHLGGhAUjqITMpdXL/ZUnSzN/U8ib0s/p41Rlej472d3NXkbyKFW3E=--bQMkphrAKssxoUy8--J00s66sXG0GcHRoPPKmG7w== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 53a7eea96..c698d932e 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg== \ No newline at end of file +GmilAzR45j/MIl29ZP7M7NkyjMrAKvQ3s4He/enEUhetB3IHVS+EotbU1RW5Zvaeq8a0NFHW8BFdF+DQ0MU25s5Tn8aDWe+GG/7HlN1O7r00Wf8bOgyCRPJAUmkkP1gMwLtS4VCFseMXancAKyOhh7MXEs9GLoj7IxczxOwo6wAmWvQQMiFewo1nWzXQIa6kEe7MGixJpmJl+FYdDmHHxNZ74le6qW3zTl5lLhCi5aC4ntfioOQPBX5psJ4yPixSaIAPucjV9pjaPaXORONtBsTp3CUA8FeLh+/lxA26JNqHR5PP0BA+KyGjJNGlK/yS33F7UsExyUBD58UADbaoeBsarXSyLP3OWhT40UgPZvkvQP7MoVsJurxjYCVIzYLMcwAqWFV1NakWp/aJqAENopD0XT0ODh1eslUN7kYYMvYg03IHevOkvz3UPhx2L/s/tyqwa3D8YGzVFxEq9Y6aMDelhJ1/T/0+8FpTMnvGQt+rXfCTzPr2AeQDO1OnLFtiksFL8AqDuKKXh6OspY2z+TgSpCR+6uqIaauh4attlmoWlG5bvwKPuCdJ4hxdaYT+bLcoqN736j7db2RGF8xNauiCxYeoqZtg8MN24geNosBnIqEIQmrejZ/UCZtFWVDhtnw/xYrjDu6/3PAxTWfTqNc/WCXrMSrO9E3upAEQKSiveWaPWFdf/opW75IQP1FSLnZ2IAHm4HH1zvvdO5aL4wwB76sGxq7UrNZIBrCsKSAemIJKimuUoZ5mdE/9ZyzeK00dDvuQ/NUulQLEfa1PcQOjvd7YErZJsiaP4BF8/d5pBv3y68rpxsHp1bl2hMkqS/ww+DpYhB37OiSpdxDSR8e7MOE=--L0z/iKlmgeOYpAtv--SJvijuKNOO/M1jGTEdzxFg== \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 45dc01625..25207bde7 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -67,4 +67,7 @@ Rails.application.configure do # Load test helpers config.autoload_paths += %w[ test/test_helpers ] + + # Auto-encrypt test fixture attributes + config.active_record.encryption.encrypt_fixtures = true end diff --git a/config/queue.yml b/config/queue.yml index f5aa8fb72..8b04d23f8 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ] threads: 3 processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> polling_interval: 0.1 diff --git a/db/migrate/20250909113219_create_webhooks.rb b/db/migrate/20250909113219_create_webhooks.rb new file mode 100644 index 000000000..9aca01aa0 --- /dev/null +++ b/db/migrate/20250909113219_create_webhooks.rb @@ -0,0 +1,12 @@ +class CreateWebhooks < ActiveRecord::Migration[8.1] + def change + create_table :webhooks do |t| + t.boolean :active, default: true, null: false + t.string :name + t.text :url, null: false + t.string :signing_secret, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20250909115638_create_webhook_deliveries.rb b/db/migrate/20250909115638_create_webhook_deliveries.rb new file mode 100644 index 000000000..876c51202 --- /dev/null +++ b/db/migrate/20250909115638_create_webhook_deliveries.rb @@ -0,0 +1,13 @@ +class CreateWebhookDeliveries < ActiveRecord::Migration[8.1] + def change + create_table :webhook_deliveries do |t| + t.belongs_to :webhook, null: false, foreign_key: true + t.belongs_to :event, null: false, foreign_key: true + t.string :state, null: false + t.text :request + t.text :response + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d52e28db..5303449d5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -419,7 +419,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false - t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "user_settings", force: :cascade do |t| @@ -456,6 +455,27 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do t.index ["user_id"], name: "index_watches_on_user_id" end + create_table "webhook_deliveries", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "event_id", null: false + t.text "request" + t.text "response" + t.string "state", null: false + t.datetime "updated_at", null: false + t.integer "webhook_id", null: false + t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" + t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" + end + + create_table "webhooks", force: :cascade do |t| + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "name" + t.string "signing_secret", null: false + t.datetime "updated_at", null: false + t.text "url", null: false + end + create_table "workflow_stages", force: :cascade do |t| t.string "color" t.datetime "created_at", null: false @@ -501,6 +521,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do add_foreign_key "user_settings", "users" add_foreign_key "watches", "cards" add_foreign_key "watches", "users" + add_foreign_key "webhook_deliveries", "events" + add_foreign_key "webhook_deliveries", "webhooks" add_foreign_key "workflow_stages", "workflows" # Virtual tables defined in this database. diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 5820b6173..36d94dfac 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1248,7 +1248,7 @@ columns: - *9 - *18 users: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active cast_type: *32 @@ -1318,6 +1318,95 @@ columns: default_function: collation: comment: + webhook_deliveries: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: event_id + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: request + cast_type: *15 + sql_type_metadata: *16 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: response + cast_type: *15 + sql_type_metadata: *16 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: state + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: webhook_id + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + webhooks: + - *37 + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: name + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: signing_secret + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: url + cast_type: *15 + sql_type_metadata: *16 + 'null': false + default: + default_function: + collation: + comment: workflow_stages: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -1397,6 +1486,8 @@ primary_keys: user_settings: id users: id watches: id + webhook_deliveries: id + webhooks: id workflow_stages: id workflows: id data_sources: @@ -1448,6 +1539,8 @@ data_sources: user_settings: true users: true watches: true + webhook_deliveries: true + webhooks: true workflow_stages: true workflows: true indexes: @@ -2858,23 +2951,7 @@ indexes: nulls_not_distinct: comment: valid: true - tags: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: tags - name: index_tags_on_account_id_and_title - unique: true - columns: - - title - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true + tags: [] user_settings: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: user_settings @@ -2991,6 +3068,40 @@ indexes: nulls_not_distinct: comment: valid: true + webhook_deliveries: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: webhook_deliveries + name: index_webhook_deliveries_on_event_id + unique: false + columns: + - event_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: webhook_deliveries + name: index_webhook_deliveries_on_webhook_id + unique: false + columns: + - webhook_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + webhooks: [] workflow_stages: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: workflow_stages diff --git a/test/fixtures/webhook/deliveries.yml b/test/fixtures/webhook/deliveries.yml new file mode 100644 index 000000000..31fc59c59 --- /dev/null +++ b/test/fixtures/webhook/deliveries.yml @@ -0,0 +1,41 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +successfully_completed: + webhook: active + event: logo_published + state: completed + request: '{"headers": {}}' + response: '{"code": 200, "headers": {}}' + created_at: <%= 1.week.ago %> + +successfully_completed: + webhook: active + event: logo_assignment_jz + state: completed + request: '{"headers": {}}' + response: '{"code": 200, "headers": {}}' + created_at: <%= 1.week.ago + 1.hour %> + +errored: + webhook: active + event: layout_published + state: errored + request: '{"headers": {}}' + response: '{"error": "destination_unreachable"}' + created_at: <%= 1.week.ago %> + +pending: + webhook: active + event: shipping_closed + state: pending + request: null + response: null + created_at: <%= 2.day.ago %> + +in_progress: + webhook: active + event: logo_assignment_km + state: in_progress + request: null + response: null + created_at: <%= 1.day.ago %> diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml new file mode 100644 index 000000000..c2d308c0a --- /dev/null +++ b/test/fixtures/webhooks.yml @@ -0,0 +1,11 @@ +active: + active: true + name: Production API + url: https://api.example.com/webhooks + signing_secret: p94Bx2HjempCdYB4DTyZkY1b + +inactive: + active: false + name: Test Webhook + url: https://test.example.com/webhooks + signing_secret: H8ms8ADcV92v2x17hnLEiL5m diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb new file mode 100644 index 000000000..1a15c05f3 --- /dev/null +++ b/test/models/webhook/delivery_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +class Webhook::DeliveryTest < ActiveSupport::TestCase + test "create" do + webhook = webhooks(:active) + event = events(:layout_commented) + delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + + assert_equal "pending", delivery.state + end + + test "succeeded" do + webhook = webhooks(:active) + event = events(:layout_commented) + delivery = Webhook::Delivery.new( + webhook: webhook, + event: event, + response: { code: 200 }, + state: :completed + ) + assert delivery.succeeded? + + delivery.response[:code] = 422 + assert_not delivery.succeeded?, "resonse must have a 2XX status" + + delivery.response[:code] = 200 + delivery.state = :pending + assert_not delivery.succeeded?, "state must be completed" + + delivery.state = :in_progress + assert_not delivery.succeeded?, "state must be completed" + + delivery.state = :errored + assert_not delivery.succeeded?, "state must be completed" + + delivery.state = :completed + delivery.response[:error] = :destination_unreachable + + assert_not delivery.succeeded?, "the response can't have an error" + end + + test "deliver_later" do + delivery = webhook_deliveries(:pending) + + assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do + delivery.deliver_later + end + end + + test "deliver" do + delivery = webhook_deliveries(:pending) + + stub_request(:post, delivery.webhook.url) + .to_return(status: 200, headers: { "content-type" => "application/json", "x-test" => "foo" }) + + assert_equal "pending", delivery.state + + delivery.deliver + + assert delivery.persisted? + assert_equal "completed", delivery.state + assert delivery.request[:headers].present? + assert_equal [ "foo" ], delivery.response[:headers]["x-test"] + assert_equal 200, delivery.response[:code] + assert delivery.response[:error].blank? + end + + test "deliver when the network timeouts" do + delivery = webhook_deliveries(:pending) + stub_request(:post, delivery.webhook.url).to_timeout + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal "connection_timeout", delivery.response[:error] + end + + test "deliver when the connection is refused" do + delivery = webhook_deliveries(:pending) + stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal "destination_unreachable", delivery.response[:error] + end + + test "deliver when an SSL error occurs" do + delivery = webhook_deliveries(:pending) + stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal "failed_tls", delivery.response[:error] + end + + test "deliver when an unexpected error occurs" do + delivery = webhook_deliveries(:pending) + stub_request(:post, delivery.webhook.url).to_raise(StandardError, "Unexpected error") + + assert_raises(StandardError) do + delivery.deliver + end + + assert_equal "errored", delivery.state + end +end diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb new file mode 100644 index 000000000..43dc66ed3 --- /dev/null +++ b/test/models/webhook_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class WebhookTest < ActiveSupport::TestCase + test "create" do + webhook = Webhook.create! name: "Test", url: "https://example.com/webhook" + assert webhook.persisted? + assert webhook.active? + assert webhook.signing_secret.present? + end + + test "validates the url" do + webhook = Webhook.new name: "Test" + assert_not webhook.valid? + assert_includes webhook.errors[:url], "not a URL" + + webhook = Webhook.new name: "Test", url: "not a url" + assert_not webhook.valid? + assert_includes webhook.errors[:url], "not a URL" + + webhook = Webhook.new name: "NOTHING", url: "example.com/webhook" + assert_not webhook.valid? + assert_includes webhook.errors[:url], "must use http or https" + + webhook = Webhook.new name: "BLANK", url: "//example.com/webhook" + assert_not webhook.valid? + assert_includes webhook.errors[:url], "must use http or https" + + webhook = Webhook.new name: "GOPHER", url: "gopher://example.com/webhook" + assert_not webhook.valid? + assert_includes webhook.errors[:url], "must use http or https" + + webhook = Webhook.new name: "HTTP", url: "http://example.com/webhook" + assert webhook.valid? + + webhook = Webhook.new name: "HTTPS", url: "https://example.com/webhook" + assert webhook.valid? + end + + test "deactivate" do + webhook = webhooks(:active) + + assert_changes -> { webhook.active? }, from: true, to: false do + webhook.deactivate + end + end +end From 9cfd1e43af07bbfa0409147adcfcccdf3132fa7c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Sep 2025 19:09:20 +0200 Subject: [PATCH 02/31] Enable subscribing to specific actions --- app/controllers/webhooks_controller.rb | 51 ++++++++++++++++ app/models/webhook.rb | 29 ++++++++++ app/models/webhook/delivery.rb | 21 ++----- app/views/account/settings/show.html.erb | 4 ++ app/views/webhooks/_delivery.html.erb | 8 +++ app/views/webhooks/_webhook.html.erb | 13 +++++ app/views/webhooks/edit.html.erb | 25 ++++++++ app/views/webhooks/form/_actions.html.erb | 16 +++++ app/views/webhooks/index.html.erb | 32 ++++++++++ app/views/webhooks/new.html.erb | 27 +++++++++ app/views/webhooks/show.html.erb | 61 ++++++++++++++++++++ config/routes.rb | 2 + db/migrate/20250909113219_create_webhooks.rb | 1 + db/schema.rb | 1 + db/schema_cache.yml | 10 ++++ test/fixtures/webhooks.yml | 2 + 16 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 app/controllers/webhooks_controller.rb create mode 100644 app/views/webhooks/_delivery.html.erb create mode 100644 app/views/webhooks/_webhook.html.erb create mode 100644 app/views/webhooks/edit.html.erb create mode 100644 app/views/webhooks/form/_actions.html.erb create mode 100644 app/views/webhooks/index.html.erb create mode 100644 app/views/webhooks/new.html.erb create mode 100644 app/views/webhooks/show.html.erb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 000000000..65299121b --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,51 @@ +class WebhooksController < ApplicationController + include FilterScoped + + before_action :set_webhook, except: %i[ index new create ] + + def index + set_page_and_extract_portion_from Webhook.all.ordered + end + + def show + end + + def new + @webhook = Webhook.new + end + + def create + @webhook = Webhook.new(webhook_params) + + if @webhook.save + redirect_to @webhook, status: :see_other + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @webhook.update(webhook_params.except(:url)) + redirect_to @webhook, status: :see_other + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @webhook.destroy! + redirect_to webhooks_path, status: :see_other + end + + private + def set_webhook + @webhook = Webhook.find(params[:id]) + end + + def webhook_params + params.require(:webhook).permit(:name, :url, subscribed_actions: []) + end +end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 6878b62b3..50bee846f 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -1,17 +1,46 @@ class Webhook < ApplicationRecord PERMITTED_SCHEMES = %w[ http https ].freeze + PERMITTED_ACTIONS = %w[ + card_assigned + card_boosted + card_closed + card_collection_changed + card_created + card_popped + card_published + card_reopened + card_staged + card_title_changed + card_unassigned + card_unstaged + comment_created + ].freeze encrypts :signing_secret has_secure_token :signing_secret has_many :deliveries, dependent: :delete_all + serialize :subscribed_actions, type: Array, coder: JSON + + scope :ordered, -> { order(name: :asc, id: :desc) } + scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } + + # normalizes :subscribed_actions, with: ->(value) { Array(value).map(&:to_s).map(&:strip).reject(&:blank?).uniq & PERMITTED_ACTIONS } + + validates :name, presence: true + validates :subscribed_actions, presence: true + validate :validate_url def deactivate update_columns active: false end + def trigger(event) + deliveries.create!(event: event).deliver_later + end + private def validate_url uri = URI.parse(url.presence) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 5e3252dea..b5baa51fd 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -3,29 +3,22 @@ class Webhook::Delivery < ApplicationRecord ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 PRIVATE_IP_RANGES = [ - # Loopback - IPAddr.new("127.0.0.0/8"), - IPAddr.new("::1/128"), # IPv4 mapped to IPv6 IPAddr.new("::ffff:0:0/96"), - # RFC1918 - local network IP addresses - IPAddr.new("10.0.0.0/8"), - IPAddr.new("172.16.0.0/12"), - IPAddr.new("192.168.0.0/16"), # Link-local (DHCP and router stuff) IPAddr.new("169.254.0.0/16"), - IPAddr.new("fe80::/10"), - # ULA - IPAddr.new("fc00::/7") + IPAddr.new("fe80::/10") ].freeze belongs_to :webhook belongs_to :event + encrypts :request, :response + store :request, coder: JSON store :response, coder: JSON - encrypts :request, :response + scope :chronologically, -> { order created_at: :asc, id: :desc } enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending @@ -80,10 +73,8 @@ class Webhook::Delivery < ApplicationRecord end end - ip_addresses.any? do |ip_address| - PRIVATE_IP_RANGES.any? do |private_ip_range| - private_ip_range.include?(ip_address) - end + ip_addresses.any? do |ip| + ip.private? || ip.loopback? || PRIVATE_IP_RANGES.any? { |range| range.include?(ip) } end end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 6d41d439d..6ab7e3bb7 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -4,6 +4,10 @@ <%= render "filters/menu", user_filtering: @user_filtering %>

<%= @page_title %>

+ <%= link_to webhooks_path, class: "btn" do %> + <%= icon_tag "world" %> + Webhooks + <% end %> <%= link_to workflows_path, class: "btn" do %> <%= icon_tag "bolt" %> Workflows diff --git a/app/views/webhooks/_delivery.html.erb b/app/views/webhooks/_delivery.html.erb new file mode 100644 index 000000000..ced1f9e2c --- /dev/null +++ b/app/views/webhooks/_delivery.html.erb @@ -0,0 +1,8 @@ +<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %> +
+ <%= delivery.state %> +
+
+ <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %> +
+<% end %> diff --git a/app/views/webhooks/_webhook.html.erb b/app/views/webhooks/_webhook.html.erb new file mode 100644 index 000000000..7f77b9a34 --- /dev/null +++ b/app/views/webhooks/_webhook.html.erb @@ -0,0 +1,13 @@ +
  • + <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width" do %> + <%= webhook.name %> + <% end %> + + + + <%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative", + data: { turbo_confirm: "Are you sure you want to permanently remove this webhook?" } do %> + <%= icon_tag "minus" %> + Remove <%= webhook.name %> + <% end %> +
  • diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb new file mode 100644 index 000000000..1c2513a1e --- /dev/null +++ b/app/views/webhooks/edit.html.erb @@ -0,0 +1,25 @@ +<% @page_title = "New Webhook" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    +<% end %> + +
    + <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +

    + <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> +

    + +
    + <%= form.label :actions %> + <%= render "webhooks/form/actions", form: form %> +
    + + <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> + Update Webhook + <% end %> + + <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
    diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb new file mode 100644 index 000000000..bd592ca20 --- /dev/null +++ b/app/views/webhooks/form/_actions.html.erb @@ -0,0 +1,16 @@ +
      + <%= form.collection_check_boxes \ + :subscribed_actions, + Webhook::PERMITTED_ACTIONS, + :itself, + :humanize do |item| %> +
    • + +
    • + <% end %> +
    diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb new file mode 100644 index 000000000..1b835634f --- /dev/null +++ b/app/views/webhooks/index.html.erb @@ -0,0 +1,32 @@ +<% @page_title = "Webhooks" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    + +
    + <%= link_to new_webhook_path, class: "btn" do %> + <%= icon_tag "add" %> + Create a new webhook + <% end %> +
    +<% end %> + +
    + <%= tag.div class: "panel shadow center", data: { + controller: "navigable-list", + action: "keydown->navigable-list#navigate", + navigable_list_focus_on_selection_value: true, + navigable_list_actionable_items_value: true + } do %> +
    +
      + <%= render partial: "webhooks/webhook", collection: @page.records %> +
    + +
    + Press to move, enter to visit webhook, SHIFT+ENTER to toggle. +
    +
    + <% end %> +
    diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb new file mode 100644 index 000000000..996e2e3ea --- /dev/null +++ b/app/views/webhooks/new.html.erb @@ -0,0 +1,27 @@ +<% @page_title = "Webhooks" %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> +

    <%= @page_title %>

    +<% end %> + +
    + <%= form_with model: @webhook, url: webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +

    + <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> +

    + + <%= form.text_field :url, required: true, class: "input", placeholder: "https://example.com", data: { action: "keydown.esc@document->form#cancel" } %> + +
    + <%= form.label :actions %> + <%= render "webhooks/form/actions", form: form %> +
    + + <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> + Create Webhook + <% end %> + + <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
    diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb new file mode 100644 index 000000000..15b4fd117 --- /dev/null +++ b/app/views/webhooks/show.html.erb @@ -0,0 +1,61 @@ +<% @page_title = @webhook.name %> + +<% content_for :header do %> + <%= render "filters/menu", user_filtering: @user_filtering %> + + <%= link_to webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + Webhooks + + <% end %> +<% end %> + +
    +
    + <%= link_to edit_webhook_path(@webhook), class: "user-edit-link btn" do %> + <%= icon_tag "pencil" %> + Edit + <% end %> + +
    +

    <%= @webhook.name %>

    +
    + +
    + <%= @webhook.url %> +
    + +
    +

    Signing secret

    +

    + <%= @webhook.signing_secret %> +

    +

    + We'll sent a `X-Webhook-Signature` header with each request. + You can generate a HMAC using SHA256 of the request body with this secret + to verify that the request came from us. +

    +
    + +
    +

    Subscribed to

    +
      + <% @webhook.subscribed_actions.each do |action| %> +
    • <%= action.humanize %>
    • + <% end %> +
    +
    + +
    +

    Deliveries

    + <% if @webhook.deliveries.empty? %> +

    This Webhook hasn't been triggered yet

    + <% else %> +

      + <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.chronologically.limit(20), as: :delivery %> +
    + <% end %> +
    +
    +
    diff --git a/config/routes.rb b/config/routes.rb index c60c7b22c..020b7e51c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,8 @@ Rails.application.routes.draw do resources :stages, module: :workflows end + resources :webhooks + resources :uploads, only: :create get "/u/*slug" => "uploads#show", as: :upload diff --git a/db/migrate/20250909113219_create_webhooks.rb b/db/migrate/20250909113219_create_webhooks.rb index 9aca01aa0..29d952552 100644 --- a/db/migrate/20250909113219_create_webhooks.rb +++ b/db/migrate/20250909113219_create_webhooks.rb @@ -4,6 +4,7 @@ class CreateWebhooks < ActiveRecord::Migration[8.1] t.boolean :active, default: true, null: false t.string :name t.text :url, null: false + t.text :subscribed_actions, index: true t.string :signing_secret, null: false t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 5303449d5..186a9a889 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -472,6 +472,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do t.datetime "created_at", null: false t.string "name" t.string "signing_secret", null: false + t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false end diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 36d94dfac..101db8857 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1396,6 +1396,16 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: subscribed_actions + cast_type: *15 + sql_type_metadata: *16 + 'null': true + default: + default_function: + collation: + comment: - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index c2d308c0a..eaf09613a 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -3,9 +3,11 @@ active: name: Production API url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b + subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %> inactive: active: false name: Test Webhook url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m + subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %> From 1930067e08b20328f9d65149c5abe5a30b1084f4 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Sep 2025 19:21:41 +0200 Subject: [PATCH 03/31] Allow Webhooks to be disabled --- app/controllers/webhooks_controller.rb | 2 +- app/views/webhooks/edit.html.erb | 5 +++++ app/views/webhooks/show.html.erb | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 65299121b..e4cf469c1 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -46,6 +46,6 @@ class WebhooksController < ApplicationController end def webhook_params - params.require(:webhook).permit(:name, :url, subscribed_actions: []) + params.require(:webhook).permit(:name, :url, :active, subscribed_actions: []) end end diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index 1c2513a1e..eee4de58e 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -11,6 +11,11 @@ <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> +
    + <%= form.label :active %> + <%= form.check_box :active %> +
    +
    <%= form.label :actions %> <%= render "webhooks/form/actions", form: form %> diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index 15b4fd117..3067125df 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -27,7 +27,11 @@
    -

    Signing secret

    + <%= @webhook.active ? "Active" : "Disabled" %> +
    + +
    +

    Secret

    <%= @webhook.signing_secret %>

    From 9d717239e7a7470720976d46f2a412770de95ad2 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 11:27:25 +0200 Subject: [PATCH 04/31] Dispatch Webhooks on event creation --- app/jobs/event/webhook_dispatch_job.rb | 16 ++++++++++++++++ app/jobs/webhook/delinquent_catcher_job.rb | 2 ++ app/models/event.rb | 10 +++++++++- app/models/event/webhook_deliverable.rb | 7 ------- app/models/webhook.rb | 2 ++ app/models/webhook/delivery.rb | 7 +++---- app/views/webhooks/edit.html.erb | 4 ++-- 7 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 app/jobs/event/webhook_dispatch_job.rb create mode 100644 app/jobs/webhook/delinquent_catcher_job.rb delete mode 100644 app/models/event/webhook_deliverable.rb diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb new file mode 100644 index 000000000..38ced3fd2 --- /dev/null +++ b/app/jobs/event/webhook_dispatch_job.rb @@ -0,0 +1,16 @@ +require "active_job/continuable" + +class Event::WebhookDispatchJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :webhooks + + def perform(event) + step :dispatch do |step| + Webhook.triggered_by(event).find_each(start: step.cursor) do |webhook| + webhook.trigger(event) + step.advance! from: webhook.id + end + end + end +end diff --git a/app/jobs/webhook/delinquent_catcher_job.rb b/app/jobs/webhook/delinquent_catcher_job.rb new file mode 100644 index 000000000..b379fda44 --- /dev/null +++ b/app/jobs/webhook/delinquent_catcher_job.rb @@ -0,0 +1,2 @@ +class Webhook::DelinquentCatcherJob +end diff --git a/app/models/event.rb b/app/models/event.rb index 5b2ed2969..c45702833 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,13 +1,16 @@ class Event < ApplicationRecord - include Notifiable, Particulars, Promptable, WebhookDeliverable + include Notifiable, Particulars, Promptable belongs_to :collection belongs_to :creator, class_name: "User" belongs_to :eventable, polymorphic: true + has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all + scope :chronologically, -> { order created_at: :asc, id: :desc } after_create -> { eventable.event_was_created(self) } + after_create_commit :dispatch_webhooks delegate :card, to: :eventable @@ -18,4 +21,9 @@ class Event < ApplicationRecord def notifiable_target eventable end + + private + def dispatch_webhooks + Event::WebhookDispatchJob.perform_later(self) + end end diff --git a/app/models/event/webhook_deliverable.rb b/app/models/event/webhook_deliverable.rb deleted file mode 100644 index 1666390bd..000000000 --- a/app/models/event/webhook_deliverable.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Event::WebhookDeliverable - extend ActiveSupport::Concern - - included do - has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all - end -end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 50bee846f..7eed364ca 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -24,7 +24,9 @@ class Webhook < ApplicationRecord serialize :subscribed_actions, type: Array, coder: JSON scope :ordered, -> { order(name: :asc, id: :desc) } + scope :active, -> { where(active: true) } scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } + scope :triggered_by, ->(event) { active.triggered_by_action(event.action) } # normalizes :subscribed_actions, with: ->(value) { Array(value).map(&:to_s).map(&:strip).reject(&:blank?).uniq & PERMITTED_ACTIONS } diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index b5baa51fd..f13ced227 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -5,9 +5,8 @@ class Webhook::Delivery < ApplicationRecord PRIVATE_IP_RANGES = [ # IPv4 mapped to IPv6 IPAddr.new("::ffff:0:0/96"), - # Link-local (DHCP and router stuff) - IPAddr.new("169.254.0.0/16"), - IPAddr.new("fe80::/10") + # Broadcasts + IPAddr.new("0.0.0.0/8") ].freeze belongs_to :webhook @@ -18,7 +17,7 @@ class Webhook::Delivery < ApplicationRecord store :request, coder: JSON store :response, coder: JSON - scope :chronologically, -> { order created_at: :asc, id: :desc } + scope :chronologically, -> { order created_at: :asc, id: :asc } enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index eee4de58e..2aa886c8c 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -1,8 +1,8 @@ -<% @page_title = "New Webhook" %> +<% @page_title = "Webhooks" %> <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> -

    <%= @page_title %>

    +

    <%= @page_title %>

    <% end %>
    From 5c6cad057f198f755a767cf1635572502505f71c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 12:59:37 +0200 Subject: [PATCH 05/31] Extract triggering logic into a concern --- app/jobs/event/webhook_dispatch_job.rb | 2 +- app/jobs/webhook/delinquent_catcher_job.rb | 2 -- app/models/webhook.rb | 10 +++------- app/models/webhook/delivery.rb | 4 +++- app/models/webhook/triggerable.rb | 12 ++++++++++++ 5 files changed, 19 insertions(+), 11 deletions(-) delete mode 100644 app/jobs/webhook/delinquent_catcher_job.rb create mode 100644 app/models/webhook/triggerable.rb diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb index 38ced3fd2..1df418cc8 100644 --- a/app/jobs/event/webhook_dispatch_job.rb +++ b/app/jobs/event/webhook_dispatch_job.rb @@ -7,7 +7,7 @@ class Event::WebhookDispatchJob < ApplicationJob def perform(event) step :dispatch do |step| - Webhook.triggered_by(event).find_each(start: step.cursor) do |webhook| + Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook| webhook.trigger(event) step.advance! from: webhook.id end diff --git a/app/jobs/webhook/delinquent_catcher_job.rb b/app/jobs/webhook/delinquent_catcher_job.rb deleted file mode 100644 index b379fda44..000000000 --- a/app/jobs/webhook/delinquent_catcher_job.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Webhook::DelinquentCatcherJob -end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 7eed364ca..02f13cadf 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -1,4 +1,6 @@ class Webhook < ApplicationRecord + include Triggerable + PERMITTED_SCHEMES = %w[ http https ].freeze PERMITTED_ACTIONS = %w[ card_assigned @@ -25,10 +27,8 @@ class Webhook < ApplicationRecord scope :ordered, -> { order(name: :asc, id: :desc) } scope :active, -> { where(active: true) } - scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } - scope :triggered_by, ->(event) { active.triggered_by_action(event.action) } - # normalizes :subscribed_actions, with: ->(value) { Array(value).map(&:to_s).map(&:strip).reject(&:blank?).uniq & PERMITTED_ACTIONS } + normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map { |e| e.to_s.strip.presence }.uniq & PERMITTED_ACTIONS } validates :name, presence: true validates :subscribed_actions, presence: true @@ -39,10 +39,6 @@ class Webhook < ApplicationRecord update_columns active: false end - def trigger(event) - deliveries.create!(event: event).deliver_later - end - private def validate_url uri = URI.parse(url.presence) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index f13ced227..9197d311e 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -17,9 +17,11 @@ class Webhook::Delivery < ApplicationRecord store :request, coder: JSON store :response, coder: JSON + enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending + scope :chronologically, -> { order created_at: :asc, id: :asc } - enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending + after_create_commit :deliver_later def deliver_later Webhook::DeliveryJob.perform_later(self) diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb new file mode 100644 index 000000000..4e8afeb43 --- /dev/null +++ b/app/models/webhook/triggerable.rb @@ -0,0 +1,12 @@ +module Webhook::Triggerable + extend ActiveSupport::Concern + + included do + scope :triggered_by, ->(event) { triggered_by_action(event.action) } + scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } + end + + def trigger(event) + deliveries.create!(event: event) + end +end From 4da69fb69f77b6535bf7c9238e314c380d88d4b7 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 13:31:38 +0200 Subject: [PATCH 06/31] Move reactivation out of edit --- .../webhooks/activations_controller.rb | 13 ++++++++++ app/controllers/webhooks_controller.rb | 2 +- app/models/webhook.rb | 6 +++-- app/models/webhook/delivery.rb | 2 +- app/views/webhooks/edit.html.erb | 5 ---- app/views/webhooks/show.html.erb | 24 ++++++++++++------- config/routes.rb | 6 ++++- 7 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 app/controllers/webhooks/activations_controller.rb diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb new file mode 100644 index 000000000..bc72998f2 --- /dev/null +++ b/app/controllers/webhooks/activations_controller.rb @@ -0,0 +1,13 @@ +class Webhooks::ActivationsController < ApplicationController + before_action :set_webhook + + def create + @webhook.activate unless @webhook.active? + redirect_to @webhook, status: :see_other + end + + private + def set_webhook + @webhook = Webhook.find(params[:webhook_id]) + end +end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index e4cf469c1..65299121b 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -46,6 +46,6 @@ class WebhooksController < ApplicationController end def webhook_params - params.require(:webhook).permit(:name, :url, :active, subscribed_actions: []) + params.require(:webhook).permit(:name, :url, subscribed_actions: []) end end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 02f13cadf..558124995 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -31,10 +31,12 @@ class Webhook < ApplicationRecord normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map { |e| e.to_s.strip.presence }.uniq & PERMITTED_ACTIONS } validates :name, presence: true - validates :subscribed_actions, presence: true - validate :validate_url + def activate + update_columns active: true + end + def deactivate update_columns active: false end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 9197d311e..387aef53b 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -19,7 +19,7 @@ class Webhook::Delivery < ApplicationRecord enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending - scope :chronologically, -> { order created_at: :asc, id: :asc } + scope :ordered, -> { order created_at: :desc, id: :desc } after_create_commit :deliver_later diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index 2aa886c8c..ceba7b793 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -11,11 +11,6 @@ <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> -
    - <%= form.label :active %> - <%= form.check_box :active %> -
    -
    <%= form.label :actions %> <%= render "webhooks/form/actions", form: form %> diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index 3067125df..1926b2cd8 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -26,9 +26,11 @@ <%= @webhook.url %>
    -
    - <%= @webhook.active ? "Active" : "Disabled" %> -
    + <% unless @webhook.active? %> +
    + <%= button_to "Reactivate", webhook_activation_path(@webhook), method: :post %> +
    + <% end %>

    Secret

    @@ -44,11 +46,15 @@

    Subscribed to

    -
      - <% @webhook.subscribed_actions.each do |action| %> -
    • <%= action.humanize %>
    • - <% end %> -
    + <% if @webhook.subscribed_actions.empty? %> +

    This Webhook doesn't subscribe to any actions. It will never trigger.

    + <% else %> +

      + <% @webhook.subscribed_actions.each do |action| %> +
    • <%= action.humanize %>
    • + <% end %> +
    + <% end %>
    @@ -57,7 +63,7 @@

    This Webhook hasn't been triggered yet

    <% else %>

      - <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.chronologically.limit(20), as: :delivery %> + <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>
    <% end %>
    diff --git a/config/routes.rb b/config/routes.rb index 020b7e51c..15262d129 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,7 +86,11 @@ Rails.application.routes.draw do resources :stages, module: :workflows end - resources :webhooks + resources :webhooks do + scope module: "webhooks" do + resource :activation, only: %i[ create ] + end + end resources :uploads, only: :create get "/u/*slug" => "uploads#show", as: :upload From 0bdbc5f5e8d0d265a9824842a9b1d75787f860e8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 16:31:53 +0200 Subject: [PATCH 07/31] Add abuse prevention --- app/models/webhook.rb | 5 +- app/models/webhook/delinquency_tracker.rb | 39 ++++++++++ app/models/webhook/delivery.rb | 5 +- ...541_create_webhook_delinquency_trackers.rb | 12 +++ db/schema.rb | 11 +++ db/schema_cache.yml | 78 ++++++++++++++++--- .../fixtures/webhook/delinquency_trackers.yml | 13 ++++ .../webhook/delinquency_tracker_test.rb | 7 ++ 8 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 app/models/webhook/delinquency_tracker.rb create mode 100644 db/migrate/20250911124541_create_webhook_delinquency_trackers.rb create mode 100644 test/fixtures/webhook/delinquency_trackers.yml create mode 100644 test/models/webhook/delinquency_tracker_test.rb diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 558124995..b35bbb4f0 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -22,13 +22,16 @@ class Webhook < ApplicationRecord has_secure_token :signing_secret has_many :deliveries, dependent: :delete_all + has_one :delinquency_tracker, dependent: :delete serialize :subscribed_actions, type: Array, coder: JSON scope :ordered, -> { order(name: :asc, id: :desc) } scope :active, -> { where(active: true) } - normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map { |e| e.to_s.strip.presence }.uniq & PERMITTED_ACTIONS } + after_create :create_delinquency_tracker! + + normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS } validates :name, presence: true validate :validate_url diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb new file mode 100644 index 000000000..cf250e373 --- /dev/null +++ b/app/models/webhook/delinquency_tracker.rb @@ -0,0 +1,39 @@ +class Webhook::DelinquencyTracker < ApplicationRecord + RESET_INTERVAL = 12.hours + MINIMUM_DELIVERIES = 50 + + belongs_to :webhook + + before_validation { self.last_reset_at ||= Time.current } + + def record_delivery_of(delivery) + if reset_due? + webhook.deactivate if delinquent? + reset + else + increment!(:total_count) + increment!(:failed_count) unless delivery.succeeded? + end + end + + private + def delinquent? + significantly_active? && all_deliveries_failed? + end + + def significantly_active? + total_count >= MINIMUM_DELIVERIES + end + + def all_deliveries_failed? + failed_count == total_count + end + + def reset + update_columns total_count: 0, failed_count: 0, last_reset_at: Time.current + end + + def reset_due? + last_reset_at.before?(RESET_INTERVAL.ago) + end +end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 387aef53b..f80e29871 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -32,9 +32,10 @@ class Webhook::Delivery < ApplicationRecord self.request[:headers] = headers self.response = perform_request - - completed! + self.state = :completed save! + + webhook.delinquency_tracker.record_delivery_of(self) rescue errored! raise diff --git a/db/migrate/20250911124541_create_webhook_delinquency_trackers.rb b/db/migrate/20250911124541_create_webhook_delinquency_trackers.rb new file mode 100644 index 000000000..9332f8f7c --- /dev/null +++ b/db/migrate/20250911124541_create_webhook_delinquency_trackers.rb @@ -0,0 +1,12 @@ +class CreateWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1] + def change + create_table :webhook_delinquency_trackers do |t| + t.belongs_to :webhook, null: false, foreign_key: true + t.datetime :last_reset_at + t.integer :total_count, default: 0, null: false + t.integer :failed_count, default: 0, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 186a9a889..0a1759a5e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -455,6 +455,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do t.index ["user_id"], name: "index_watches_on_user_id" end + create_table "webhook_delinquency_trackers", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "failed_count", default: 0, null: false + t.datetime "last_reset_at" + t.integer "total_count", default: 0, null: false + t.datetime "updated_at", null: false + t.integer "webhook_id", null: false + t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" + end + create_table "webhook_deliveries", force: :cascade do |t| t.datetime "created_at", null: false t.integer "event_id", null: false @@ -522,6 +532,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do add_foreign_key "user_settings", "users" add_foreign_key "watches", "cards" add_foreign_key "watches", "users" + add_foreign_key "webhook_delinquency_trackers", "webhooks" add_foreign_key "webhook_deliveries", "events" add_foreign_key "webhook_deliveries", "webhooks" add_foreign_key "workflow_stages", "workflows" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 101db8857..e390ad7ea 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1248,7 +1248,7 @@ columns: - *9 - *18 users: - - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &38 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active cast_type: *32 @@ -1318,6 +1318,50 @@ columns: default_function: collation: comment: + webhook_delinquency_trackers: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: failed_count + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 0 + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: last_reset_at + cast_type: *1 + sql_type_metadata: *2 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: total_count + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 0 + default_function: + collation: + comment: + - *9 + - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: webhook_id + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: webhook_deliveries: - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -1362,18 +1406,9 @@ columns: collation: comment: - *9 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: webhook_id - cast_type: *3 - sql_type_metadata: *4 - 'null': false - default: - default_function: - collation: - comment: - webhooks: - *37 + webhooks: + - *38 - *5 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -1496,6 +1531,7 @@ primary_keys: user_settings: id users: id watches: id + webhook_delinquency_trackers: id webhook_deliveries: id webhooks: id workflow_stages: id @@ -1549,6 +1585,7 @@ data_sources: user_settings: true users: true watches: true + webhook_delinquency_trackers: true webhook_deliveries: true webhooks: true workflow_stages: true @@ -3078,6 +3115,23 @@ indexes: nulls_not_distinct: comment: valid: true + webhook_delinquency_trackers: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: webhook_delinquency_trackers + name: index_webhook_delinquency_trackers_on_webhook_id + unique: false + columns: + - webhook_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true webhook_deliveries: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: webhook_deliveries diff --git a/test/fixtures/webhook/delinquency_trackers.yml b/test/fixtures/webhook/delinquency_trackers.yml new file mode 100644 index 000000000..3a955a4b9 --- /dev/null +++ b/test/fixtures/webhook/delinquency_trackers.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + webhook: one + last_reset_at: 2025-09-11 14:45:43 + total_count: 1 + failed_count: 1 + +two: + webhook: two + last_reset_at: 2025-09-11 14:45:43 + total_count: 1 + failed_count: 1 diff --git a/test/models/webhook/delinquency_tracker_test.rb b/test/models/webhook/delinquency_tracker_test.rb new file mode 100644 index 000000000..959d63ca4 --- /dev/null +++ b/test/models/webhook/delinquency_tracker_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 1d69b171b61054f3493b908db3b6fb2003aa9034 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 16:56:54 +0200 Subject: [PATCH 08/31] Test the abuse prevention logic --- .../fixtures/webhook/delinquency_trackers.yml | 12 +++--- test/fixtures/webhook/deliveries.yml | 22 ++++++---- .../webhook/delinquency_tracker_test.rb | 41 +++++++++++++++++-- test/models/webhook/delivery_test.rb | 5 ++- test/models/webhook_test.rb | 9 ++++ 5 files changed, 72 insertions(+), 17 deletions(-) diff --git a/test/fixtures/webhook/delinquency_trackers.yml b/test/fixtures/webhook/delinquency_trackers.yml index 3a955a4b9..b4d4230b7 100644 --- a/test/fixtures/webhook/delinquency_trackers.yml +++ b/test/fixtures/webhook/delinquency_trackers.yml @@ -1,13 +1,13 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -one: - webhook: one - last_reset_at: 2025-09-11 14:45:43 +active_webhook_tracker: + webhook: active + last_reset_at: <%= 1.hour.ago %> total_count: 1 failed_count: 1 -two: - webhook: two - last_reset_at: 2025-09-11 14:45:43 +inactive_webhook_tracker: + webhook: inactive + last_reset_at: <%= 1.hour.ago %> total_count: 1 failed_count: 1 diff --git a/test/fixtures/webhook/deliveries.yml b/test/fixtures/webhook/deliveries.yml index 31fc59c59..bc0464141 100644 --- a/test/fixtures/webhook/deliveries.yml +++ b/test/fixtures/webhook/deliveries.yml @@ -4,24 +4,32 @@ successfully_completed: webhook: active event: logo_published state: completed - request: '{"headers": {}}' - response: '{"code": 200, "headers": {}}' + request: + headers: {} + response: + code: 200 + headers: {} created_at: <%= 1.week.ago %> -successfully_completed: +unsuccessfully_completed: webhook: active event: logo_assignment_jz state: completed - request: '{"headers": {}}' - response: '{"code": 200, "headers": {}}' + request: + headers: {} + response: + code: 422 + headers: {} created_at: <%= 1.week.ago + 1.hour %> errored: webhook: active event: layout_published state: errored - request: '{"headers": {}}' - response: '{"error": "destination_unreachable"}' + request: + headers: {} + response: + error: "destination_unreachable" created_at: <%= 1.week.ago %> pending: diff --git a/test/models/webhook/delinquency_tracker_test.rb b/test/models/webhook/delinquency_tracker_test.rb index 959d63ca4..8e24ab421 100644 --- a/test/models/webhook/delinquency_tracker_test.rb +++ b/test/models/webhook/delinquency_tracker_test.rb @@ -1,7 +1,42 @@ require "test_helper" class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "record_delivery_of" do + tracker = webhook_delinquency_trackers(:active_webhook_tracker) + webhook = tracker.webhook + successful_delivery = webhook_deliveries(:successfully_completed) + failed_delivery = webhook_deliveries(:errored) + + assert_difference -> { tracker.reload.total_count }, +1 do + assert_no_difference -> { tracker.reload.failed_count } do + tracker.record_delivery_of(successful_delivery) + end + end + + assert_difference -> { tracker.reload.total_count }, +1 do + assert_difference -> { tracker.reload.failed_count }, +1 do + tracker.record_delivery_of(failed_delivery) + end + end + + travel_to 13.hours.from_now do + tracker.update!(total_count: 10, failed_count: 5) + + tracker.record_delivery_of(successful_delivery) + tracker.reload + + assert_equal 0, tracker.total_count + assert_equal 0, tracker.failed_count + assert tracker.last_reset_at > 1.minute.ago + end + + travel_to 26.hours.from_now do + tracker.update!(total_count: 50, failed_count: 50) + webhook.activate + + assert_changes -> { webhook.reload.active? }, from: true, to: false do + tracker.record_delivery_of(failed_delivery) + end + end + end end diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 1a15c05f3..d0fdaeae9 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -55,7 +55,10 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_equal "pending", delivery.state - delivery.deliver + tracker = delivery.webhook.delinquency_tracker + assert_difference -> { tracker.reload.total_count }, 1 do + delivery.deliver + end assert delivery.persisted? assert_equal "completed", delivery.state diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb index 43dc66ed3..096b2223a 100644 --- a/test/models/webhook_test.rb +++ b/test/models/webhook_test.rb @@ -6,6 +6,7 @@ class WebhookTest < ActiveSupport::TestCase assert webhook.persisted? assert webhook.active? assert webhook.signing_secret.present? + assert webhook.delinquency_tracker.present? end test "validates the url" do @@ -43,4 +44,12 @@ class WebhookTest < ActiveSupport::TestCase webhook.deactivate end end + + test "activate" do + webhook = webhooks(:inactive) + + assert_changes -> { webhook.active? }, from: false, to: true do + webhook.activate + end + end end From 4879995013aef6f450be26eab4209f219afc68ab Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Sep 2025 17:56:28 +0200 Subject: [PATCH 09/31] Serialize events --- app/models/webhook.rb | 4 +++ app/models/webhook/delivery.rb | 2 +- app/views/cards/_card.json.jbuilder | 26 +++++++++++++++++++ .../cards/comments/_comment.json.jbuilder | 18 +++++++++++++ .../collections/_collection.json.jbuilder | 16 ++++++++++++ app/views/users/_user.json.jbuilder | 7 +++++ app/views/webhooks/event.json.jbuilder | 19 ++++++++++++++ app/views/workflows/_workflow.json.jbuilder | 6 +++++ .../workflows/stages/_stage.json.jbuilder | 9 +++++++ 9 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 app/views/cards/_card.json.jbuilder create mode 100644 app/views/cards/comments/_comment.json.jbuilder create mode 100644 app/views/collections/_collection.json.jbuilder create mode 100644 app/views/users/_user.json.jbuilder create mode 100644 app/views/webhooks/event.json.jbuilder create mode 100644 app/views/workflows/_workflow.json.jbuilder create mode 100644 app/views/workflows/stages/_stage.json.jbuilder diff --git a/app/models/webhook.rb b/app/models/webhook.rb index b35bbb4f0..fc905d1c2 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -44,6 +44,10 @@ class Webhook < ApplicationRecord update_columns active: false end + def renderer + @renderer ||= ApplicationController.renderer.new + end + private def validate_url uri = URI.parse(url.presence) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index f80e29871..4cd926107 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -106,6 +106,6 @@ class Webhook::Delivery < ApplicationRecord end def payload - { test: :test }.to_json + webhook.renderer.render(template: "webhooks/event", assigns: { event: event }, format: :json) end end diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder new file mode 100644 index 000000000..fdb242edd --- /dev/null +++ b/app/views/cards/_card.json.jbuilder @@ -0,0 +1,26 @@ +json.cache! card do + json.(card, :id, :title, :status) + json.image_url card.image.presence && url_for(card.image) + + json.golden card.golden? + json.last_active_at card.last_active_at.utc + json.created_at card.created_at.utc + + json.url card_url(card) + + json.collection do + json.partial! "collections/collection", locals: { collection: card.collection } + end + + json.stage do + if card.stage + json.partial! "workflows/stages/stage", stage: card.stage + else + nil + end + end + + json.creator do + json.partial! "users/user", user: card.creator + end +end diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder new file mode 100644 index 000000000..a66ae1ef9 --- /dev/null +++ b/app/views/cards/comments/_comment.json.jbuilder @@ -0,0 +1,18 @@ +json.cache! comment do + json.(comment, :id) + + json.created_at comment.created_at.utc + json.updated_at comment.updated_at.utc + + json.body do + json.plain_text comment.body.to_plain_text + json.html comment.body.to_s + end + + json.creator do + json.partial! "users/user", user: comment.creator + end + + json.reactions_url card_comment_reactions_url(comment.card_id, comment.id) + json.url card_comment_url(comment.card_id, comment.id) +end diff --git a/app/views/collections/_collection.json.jbuilder b/app/views/collections/_collection.json.jbuilder new file mode 100644 index 000000000..3c42b2fc9 --- /dev/null +++ b/app/views/collections/_collection.json.jbuilder @@ -0,0 +1,16 @@ +json.cache! collection do + json.(collection, :id, :name, :all_access) + json.created_at collection.created_at.utc + + json.creator do + json.partial! "users/user", user: collection.creator + end + + json.workflow do + if collection.workflow + json.partial! "workflows/workflow", workflow: collection.workflow + else + nil + end + end +end diff --git a/app/views/users/_user.json.jbuilder b/app/views/users/_user.json.jbuilder new file mode 100644 index 000000000..6b11459f3 --- /dev/null +++ b/app/views/users/_user.json.jbuilder @@ -0,0 +1,7 @@ +json.cache! user do + json.(user, :id, :name, :email_address, :role, :active) + + json.created_at user.created_at.utc + + json.url user_url(user) +end diff --git a/app/views/webhooks/event.json.jbuilder b/app/views/webhooks/event.json.jbuilder new file mode 100644 index 000000000..ff779c39a --- /dev/null +++ b/app/views/webhooks/event.json.jbuilder @@ -0,0 +1,19 @@ +json.cache! @event do + json.(@event, :id, :action) + json.created_at @event.created_at.utc + + json.eventable do + case @event.eventable + when Card then json.partial! "cards/card", card: @event.eventable + when Comment then json.partial! "cards/comments/comment", comment: @event.eventable + end + end + + json.collection do + json.partial! "collections/collection", locals: { collection: @event.collection } + end + + json.creator do + json.partial! "users/user", user: @event.creator + end +end diff --git a/app/views/workflows/_workflow.json.jbuilder b/app/views/workflows/_workflow.json.jbuilder new file mode 100644 index 000000000..7fa32c7e9 --- /dev/null +++ b/app/views/workflows/_workflow.json.jbuilder @@ -0,0 +1,6 @@ +json.cache! workflow do + json.(workflow, :id, :name) + + json.created_at workflow.created_at.utc + json.updated_at workflow.updated_at.utc +end diff --git a/app/views/workflows/stages/_stage.json.jbuilder b/app/views/workflows/stages/_stage.json.jbuilder new file mode 100644 index 000000000..bb9354261 --- /dev/null +++ b/app/views/workflows/stages/_stage.json.jbuilder @@ -0,0 +1,9 @@ +json.cache! stage do + json.(stage, :id, :name, :color) + + json.created_at stage.created_at.utc + + json.workflow do + json.partial! "workflows/workflow", workflow: stage.workflow + end +end From 2fc9215b1d71c3ba4e7f2c5bf6e47e330f63ba0a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:32:00 +0200 Subject: [PATCH 10/31] Add support for Basecamp, Campfire & Slack webhooks --- app/models/webhook.rb | 17 +++++- app/models/webhook/delivery.rb | 33 ++++++++++- app/views/webhooks/event.html.erb | 4 ++ test/models/webhook/delivery_test.rb | 83 ++++++++++++++++++++++++++++ test/models/webhook_test.rb | 51 +++++++++++++++++ 5 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 app/views/webhooks/event.html.erb diff --git a/app/models/webhook.rb b/app/models/webhook.rb index fc905d1c2..647c14600 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -1,10 +1,13 @@ class Webhook < ApplicationRecord include Triggerable + SLACK_WEBHOOK_URL_REGEX = %r{//hooks\.slack\.com/services/T[^\/]+/B[^\/]+/[^\/]+\Z}i + CAMPFIRE_WEBHOOK_URL_REGEX = %r{/rooms/\d+/\d+-[^\/]+/messages\Z}i + BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX = %r{/\d+/integrations/[^\/]+/buckets/\d+/chats/\d+/lines\Z}i + PERMITTED_SCHEMES = %w[ http https ].freeze PERMITTED_ACTIONS = %w[ card_assigned - card_boosted card_closed card_collection_changed card_created @@ -48,6 +51,18 @@ class Webhook < ApplicationRecord @renderer ||= ApplicationController.renderer.new end + def for_basecamp? + url.match? BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX + end + + def for_campfire? + url.match? CAMPFIRE_WEBHOOK_URL_REGEX + end + + def for_slack? + url.match? SLACK_WEBHOOK_URL_REGEX + end + private def validate_url uri = URI.parse(url.presence) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 4cd926107..db37e20f2 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -106,6 +106,37 @@ class Webhook::Delivery < ApplicationRecord end def payload - webhook.renderer.render(template: "webhooks/event", assigns: { event: event }, format: :json) + @payload ||= if webhook.for_basecamp? + { line: { content: render_payload(formats: :html) } }.to_json + 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 diff --git a/app/views/webhooks/event.html.erb b/app/views/webhooks/event.html.erb new file mode 100644 index 000000000..038c5971d --- /dev/null +++ b/app/views/webhooks/event.html.erb @@ -0,0 +1,4 @@ +<%= event_action_sentence(@event) %> +<% if @event.eventable %> + <%= link_to "See more", @event.eventable %> +<% end %> diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index d0fdaeae9..a45c89059 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -108,4 +108,87 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_equal "errored", delivery.state end + + test "deliver with basecamp webhook format" do + webhook = Webhook.create!( + name: "Basecamp", + url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" + ) + event = events(:layout_commented) + delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + + request_stub = stub_request(:post, webhook.url) + .with do |request| + body = JSON.parse(request.body) + body.key?("line") && body["line"].key?("content") && body["line"]["content"].present? + end + .to_return(status: 200) + + delivery.deliver + + assert_requested request_stub + assert delivery.succeeded? + end + + test "deliver with campfire webhook format" do + webhook = Webhook.create!( + name: "Campfire", + url: "https://example.com/rooms/123/456-room-name/messages" + ) + event = events(:layout_commented) + delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + + request_stub = stub_request(:post, webhook.url) + .with do |request| + request.body.is_a?(String) && !request.body.start_with?("{") && request.body.present? + end + .to_return(status: 200) + + delivery.deliver + + assert_requested request_stub + assert delivery.succeeded? + end + + test "deliver with slack webhook format" do + webhook = Webhook.create!( + name: "Slack", + url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" + ) + event = events(:layout_commented) + delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + + request_stub = stub_request(:post, webhook.url) + .with do |request| + body = JSON.parse(request.body) + body.key?("text") && body["text"].present? + end + .to_return(status: 200) + + delivery.deliver + + assert_requested request_stub + assert delivery.succeeded? + end + + test "deliver with generic webhook format" do + webhook = Webhook.create!( + name: "Generic", + url: "https://example.com/webhook" + ) + event = events(:layout_commented) + delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + + request_stub = stub_request(:post, webhook.url) + .with do |request| + body = JSON.parse(request.body) + body.present? && !body.key?("line") && !body.key?("text") + end + .to_return(status: 200) + + delivery.deliver + + assert_requested request_stub + assert delivery.succeeded? + end end diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb index 096b2223a..ac2ddc748 100644 --- a/test/models/webhook_test.rb +++ b/test/models/webhook_test.rb @@ -52,4 +52,55 @@ class WebhookTest < ActiveSupport::TestCase webhook.activate end end + + test "for_slack?" do + webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" + assert webhook.for_slack? + + webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678" + assert_not webhook.for_slack? + + webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678" + assert_not webhook.for_slack? + + webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/" + assert_not webhook.for_slack? + + webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + assert_not webhook.for_slack? + end + + test "for_campfire?" do + webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/messages" + assert webhook.for_campfire? + + webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123-test-room/messages" + assert webhook.for_campfire? + + webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123/messages" + assert_not webhook.for_campfire?, "The bot key is missing a token" + + webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + assert_not webhook.for_campfire? + + webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/messages" + assert_not webhook.for_campfire? + + webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/" + assert_not webhook.for_campfire? + end + + test "for_basecamp?" do + webhook = Webhook.new name: "Test", url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines" + assert webhook.for_basecamp? + + webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + assert_not webhook.for_basecamp? + + webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/" + assert_not webhook.for_basecamp? + + webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines" + assert_not webhook.for_basecamp? + end end From 680a69ad29c36b00b21081a73c34b50a758ce8e1 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:51:23 +0200 Subject: [PATCH 11/31] Add tests for webhooks controllers --- .../webhooks/activations_controller_test.rb | 19 +++ test/controllers/webhooks_controller_test.rb | 113 ++++++++++++++++++ test/fixtures/webhooks.yml | 4 +- 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 test/controllers/webhooks/activations_controller_test.rb create mode 100644 test/controllers/webhooks_controller_test.rb diff --git a/test/controllers/webhooks/activations_controller_test.rb b/test/controllers/webhooks/activations_controller_test.rb new file mode 100644 index 000000000..228382a64 --- /dev/null +++ b/test/controllers/webhooks/activations_controller_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "create" do + webhook = webhooks(:inactive) + + assert_not webhook.active? + + assert_changes -> { webhook.reload.active? }, from: false, to: true do + post webhook_activation_path(webhook) + end + + assert_redirected_to webhook_path(webhook) + end +end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb new file mode 100644 index 000000000..dbfab6e79 --- /dev/null +++ b/test/controllers/webhooks_controller_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +class WebhooksControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index" do + get webhooks_path + assert_response :success + end + + test "show" do + webhook = webhooks(:active) + get webhook_path(webhook) + assert_response :success + end + + test "new" do + get new_webhook_path + assert_response :success + assert_select "form" + end + + test "create with valid params" do + assert_difference "Webhook.count", 1 do + post webhooks_path, params: { + webhook: { + name: "Test Webhook", + url: "https://example.com/webhook", + subscribed_actions: [ "", "card_created", "card_closed" ] + } + } + end + + webhook = Webhook.order(id: :desc).first + + assert_redirected_to webhook_path(webhook) + assert_equal "Test Webhook", webhook.name + assert_equal "https://example.com/webhook", webhook.url + assert_equal [ "card_created", "card_closed" ], webhook.subscribed_actions + end + + test "create with invalid params" do + assert_no_difference "Webhook.count" do + post webhooks_path, params: { + webhook: { + name: "", + url: "invalid-url" + } + } + end + + assert_response :unprocessable_entity + assert_select "form" + end + + test "edit" do + webhook = webhooks(:active) + get edit_webhook_path(webhook) + assert_response :success + assert_select "form" + end + + test "update with valid params" do + webhook = webhooks(:active) + patch webhook_path(webhook), params: { + webhook: { + name: "Updated Webhook", + subscribed_actions: [ "card_created" ] + } + } + + webhook.reload + + assert_redirected_to webhook_path(webhook) + assert_equal "Updated Webhook", webhook.name + assert_equal [ "card_created" ], webhook.subscribed_actions + end + + test "update with invalid params" do + webhook = webhooks(:active) + patch webhook_path(webhook), params: { + webhook: { + name: "" + } + } + + assert_response :unprocessable_entity + assert_select "form" + + assert_no_changes -> { webhook.reload.url } do + patch webhook_path(webhook), params: { + webhook: { + name: "Updated Webhook", + url: "https://different.com/webhook" + } + } + end + + assert_redirected_to webhook_path(webhook) + end + + test "destroy" do + webhook = webhooks(:active) + + assert_difference "Webhook.count", -1 do + delete webhook_path(webhook) + end + + assert_redirected_to webhooks_path + end +end diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index eaf09613a..4317606fe 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -3,11 +3,11 @@ active: name: Production API url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b - subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %> + subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' inactive: active: false name: Test Webhook url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m - subscribed_actions: <%= %w[ card_published card_assigned card_closed ].to_json %> + subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' From 1b09a39b1a718ac50f830a58d06fadd66c350490 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:53:22 +0200 Subject: [PATCH 12/31] Adhere to the redirect and params sanitization convention --- app/controllers/webhooks_controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 65299121b..c40fc72c2 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -18,7 +18,7 @@ class WebhooksController < ApplicationController @webhook = Webhook.new(webhook_params) if @webhook.save - redirect_to @webhook, status: :see_other + redirect_to @webhook else render :new, status: :unprocessable_entity end @@ -29,7 +29,7 @@ class WebhooksController < ApplicationController def update if @webhook.update(webhook_params.except(:url)) - redirect_to @webhook, status: :see_other + redirect_to @webhook else render :edit, status: :unprocessable_entity end @@ -46,6 +46,6 @@ class WebhooksController < ApplicationController end def webhook_params - params.require(:webhook).permit(:name, :url, subscribed_actions: []) + params.expect webhook: [ :name, :url, subscribed_actions: [] ] end end From 5e4d15d802161e0092ce597de9c15a6640907e3b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:57:45 +0200 Subject: [PATCH 13/31] Remove concurrency limit It won't be useful in practice for now, and we can always put it back in if the need arises --- app/jobs/webhook/delivery_job.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb index 4ec08b900..95102b9d6 100644 --- a/app/jobs/webhook/delivery_job.rb +++ b/app/jobs/webhook/delivery_job.rb @@ -1,6 +1,5 @@ class Webhook::DeliveryJob < ApplicationJob queue_as :webhooks - limits_concurrency to: 1, key: ->(delivery) { delivery.webhook_id } def perform(delivery) delivery.deliver From b8317ce88d08f7e3cb05bc19bfb9c86d38b19b94 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:58:39 +0200 Subject: [PATCH 14/31] Fix indentation --- app/models/webhook/delivery.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index db37e20f2..f3988227d 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -70,7 +70,7 @@ class Webhook::Delivery < ApplicationRecord ip_addresses = [] Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| - dns.each_address(uri.host) do |ip_address| + dns.each_address(uri.host) do |ip_address| ip_addresses << IPAddr.new(ip_address) end end From df87ee8b3540a0edbd781fb3aacc700da7c46174 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 15:59:43 +0200 Subject: [PATCH 15/31] Rename private IP ranges to disallowed IP ranges --- app/models/webhook/delivery.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index f3988227d..9c6ee975e 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -2,7 +2,7 @@ class Webhook::Delivery < ApplicationRecord USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 - PRIVATE_IP_RANGES = [ + DISALLOWED_IP_RANGES = [ # IPv4 mapped to IPv6 IPAddr.new("::ffff:0:0/96"), # Broadcasts @@ -76,7 +76,7 @@ class Webhook::Delivery < ApplicationRecord end ip_addresses.any? do |ip| - ip.private? || ip.loopback? || PRIVATE_IP_RANGES.any? { |range| range.include?(ip) } + ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } end end From d7db9733974faf074e23a3d9e3d09f1b7b53cbcf Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 16:01:11 +0200 Subject: [PATCH 16/31] Use the same timestamp from the body in the header Else replay attack prevention doesn't work --- app/models/webhook/delivery.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 9c6ee975e..f53d1f8cf 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -97,7 +97,7 @@ class Webhook::Delivery < ApplicationRecord "User-Agent" => USER_AGENT, "Content-Type" => "application/json", "X-Webhook-Signature" => signature, - "X-Webhook-Timestamp" => Time.current.utc.iso8601 + "X-Webhook-Timestamp" => event.created_at.utc.iso8601 } end From 9d77b17b8c900e41ef1369e8664e5485b338a888 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 17:17:49 +0200 Subject: [PATCH 17/31] Remove encryption --- app/models/webhook.rb | 1 - app/models/webhook/delivery.rb | 4 +--- config/credentials/beta.yml.enc | 2 +- config/credentials/development.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/credentials/staging.yml.enc | 2 +- config/credentials/test.yml.enc | 2 +- config/environments/test.rb | 3 --- test/fixtures/webhook/deliveries.yml | 20 ++++++-------------- test/models/webhook/delivery_test.rb | 3 +-- 10 files changed, 13 insertions(+), 28 deletions(-) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 647c14600..defb57186 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -21,7 +21,6 @@ class Webhook < ApplicationRecord comment_created ].freeze - encrypts :signing_secret has_secure_token :signing_secret has_many :deliveries, dependent: :delete_all diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index f53d1f8cf..3624a2d0b 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -12,8 +12,6 @@ class Webhook::Delivery < ApplicationRecord belongs_to :webhook belongs_to :event - encrypts :request, :response - store :request, coder: JSON store :response, coder: JSON @@ -54,7 +52,7 @@ class Webhook::Delivery < ApplicationRecord Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } ) - { code: response.code.to_i, headers: response.to_hash } + { code: response.code.to_i } end rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError { error: :dns_lookup_failed } diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index bab48ab9d..5b5f616ab 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -fYKvBhjXnt2n6LuJUgfMQVDoMcmxB7eSCHv97u4HtXcii1JFEvlFXBMnjz4+4QPcXMlp324db/bfCwSEed+iA9/TTt1HoYYuxypnbiaMnr2HTZVzECLWrlnOvuw9IUIYkSyQ4biW5Tq4VeU1GKpHya5Giop0R3SQKcONMrDhpBcUJ2TepCFYjCp+U+SvsRfUiMnyWk/8e5P+16qSk/s3AnlnlCbX/8kqX3HF0zrXW/tcdVIAvYrMKSzcyixH2YS6G9BCe5cvvL3e3FTnImxscc/cVecXHDDID00yG2brWpgetJamhHpfLmbiPgCpAtz1vtAEDZRJHtXw88AkCJ8wvKIZB+ZMZV3uJaxZkjx1WDDfZUP+Tw43Hq7SrxwGyCmyC8hVAJez1fQh8SoT8tGJcR30UKBxa339K0slXenLsrYc5eVbwSaxEbqgIjJWDdmY9vZ7fhO397WSBaF2tK0aBU36uRy2XMeCmLZX2lnA+gLFSt1D3XMw/sVnEHdYxuFMWg1ScnMlh0nfMMVjpSRXhcd97I/fWnJ/iWcNM+CBMvjCnjVy/6T4ushPO2lfiFAcQAqURDkuKFVpDrQ19V+aWx7uppQrGMvO8GqX+eTR2qoSdk/nnB9f5DsORVx8IBLkCz4p4JZMJiQTOTcp7HhxJf6TEqjCD9E4FwRqZG0VL7o3ZiHrls5yEV4JvNJX1tQZMimttdHP1yUhuR8wtAAQUziNeERq5vuKYhWMOVC8sxnaCksIqfwT6SX6MuwviuwdlREcd/WL4u651dusI1PLdl18n2V4opEJcqr/z4GXHnz2+X+PXTdLGrWn9YdIW0Crm8OVhGHQF/K/zg4FUNPhI6JJm/8Rw4gTdDjniF0C0DPOKZW/7Rrk68g9tn1zB6USDjXcHcxE5rg9YFHXGCnrV2Jzd4JF3M+VLgA4wzYk9IjU6PVqtjWpwRXy09EvbspsLgSYDH4LYb3nCkj/CCYTOnI0l/6ofS63kjyNYG/YD5zKpiRcgU18qiQo1uoIS1QijAt3PMggpIbl9gkcgdbed9qd8vptWNHCFKtNmFo2FKJxsLHg+4OzSotl5PYnYzisFFQyMTKuX541fIMtynD2IgazHmE3p2NdUQKGsZXrXrD35EpDCTiHNQf6Emz30xZ2md7R0NZ4043wezZ6w8hzQSPpVPIrCsrcR5UnfO6KoBeC+iMDUS9+QKim+o7OIjggeE8yDS8lsHASRX05+uUGzz3wId6tvUtgJrXvZbGdFNWCfthvcTeb0m8zGRhHBG3aic3GQ6Hwl5TyXH1lo6v66mJWwPL3YhiJu4kyowLz/2DKiMdvuGAqkqCgokpONzraDum3Mid2N/5B/z0xNnaTpqwWW7eyGc7c/1seNZUgA7Maz7eb1wMv72Xl3VJcxoFYANbCKBcRpFL8ctwAhE3QLojqb4EKDhI31wXynu/i5VVU2kNedaBT8rGhoWvZjrd4v4PNzKy65PJxKkBkQKeeXMJMOmVm0BY4QyCOZW0B/H0z867uB9CjA7Xcz7Wl/5dflawlV1mpr6EQECZd+h8v5mGFeOs0zS87q0xQVfhAcTqYGZKjSIrZZdvppKW1PXc8rNFV/3HbawnPeot4RoUx17bnJmZsdbjYtpq8tAvCH5WRdXsJFt4NvPFdMB9fRmVOZs/XoAtyjie9PJ8cZqjkDk0yRPcB0vOqYLVLuoVm6TrRPtlKTAb4uLj0mM4e--srcdud6UpP4PxVue--MRrzzScZrcPeG9j5CpQt3A== \ No newline at end of file +SlqumuYcQYbvuwMZNdQf54cXZ4zBavRLDyl6eiWzEYe/nGlGOlzAYpsPs3ZUA4bJC9tVm8c29E0FzmmG5X6b18ShCB8y6qMiSMEU8CeyMCIlvi0+CeWQPMbujA8ro5P6eE8deZ4+nN1pEqCpK8XCx+HFypx5sLpZ9G2uAPOBNPtOqX9KGXvWfe6RyHvR6+mk+a0jpPs50y2sQvA+/l77h6VnT/uoaa7P6q0YkZWmCWwsPT5ikgP5w+Ur+yn/VfWWS70CIqVSZhZJrzq96OV6GxKLLFdE/2im83Gg2vKODbu10FiQAdjwyubietv4PHLagtXux/mGRtBAzvwt2mKJpOxZFbUYr3u4luplTAxwMMvGMoEvxXs8ujTHjcWL8BCd3TjjOvuB82+/hYI/ds28GwMxDmurKlP0TfQXEUmrx27+mF5Bi/BNyaGwD3EMpP5f71HWxTgdTI2SVbM1pVHS3wnnnhvI4r9TcApt4oasZ2HcUJ1tqYvxDGShFnlsjB0XfJYQg5IM7/6PBr2FMi+7czz7gSaCiUJ/ew5HuTzXzS1NdNKaP407SIMjB8yKGmOTPlIf2Pe/i5kj6CVVV/ALPantfTddqpEXDjfSCYv+UwY5dKGKZXFzcbtQa9/LB+q1tPTi8Xo94CnxrSeJIzUJ6P3VXM9ihQn72GWrMz5Mt58VTf6yQve3gTq0gOIpLpMhOwwsWJ0tvOu5/4ma95qG4WTb7XKJIwmT6p0T7dBm8CJgigZckkme+Qob5nxUzg9pc1YQ9t5a3r0E1wCEdEeqXzBHgLfjjrF2ohYJwGWyw0zUqRxVYgrThpsIA9x4DqzFOAMRP76IV7WTCNTCoGslteV0hSFctBp1dAPUnN9bmvoWs4VS6n/tOxruYwEKLKGXzfo897LSMK6lEa1YF5r7BJIwSC1LxQ/60zuW8JYFV/4gyR3e7J2u0apExCZyohCDHgA/5Ol2mANuXIScFo6ikBJBKDWEN1GBjEA6aw0G0cY34JXJ73oN3JH8PRDmDAmd5RA3S97lyadPZxmvssN4pvncA6wj6ow3u27+TS+yDVYhtiri5Ce661Ps5sU9ih1EvxDvaoPTQnsWsvZ4/A3ybO0aNGDwlDcziNWV7+DZ6dDO6MHC93M6REYtDIjvtfSmnGC+8GNwoaa5rDCtrtbs4dSCYe3CIn9oLS03GgoTFdTpPKPNucwjN3yDIWGQ/HNJt2eCZvdVuljDWOiHR+1/rZZR/JNVvfhQRJdr+jT0gom0b9o/K3xh6N7VCPZMxalQHgELCkNa/+9WZ28L+6gpi7lEKy8dXdIoNlNE5Mpre/PiOHJViQi1ZquIM6h8fT09hv5bnT3s6zumdL7piUljRr00buCqWGF6UYNf3/3e6WeYPCKgxcDfVvi49AL4zrPKeEeW2KUwIAC0Vz0gbdh1bAAmdtjuQd4bXMMohcend69KV1nLByuLksmnyfHMXNnYmj0YHQI=--d+g/vj0Xg+A57CDr--GutJH+N6jhC1+wTQ52DuCA== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index b5d652eed..2f25a36ca 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -DaJ7nO7vhM/zZdRzh0AeG3bZBvfMp38E70+jOoUG/oLykxrem/qoKauYuTuQXadi0EzN09SYeWmPeA/p05PcNNOsw1IvpoQnrUqt3Jl34BKpW+3dcuVAafEiFR5Cvg8XOQu6Wu7KBklw8ko+TffCFkF/dqQ+60/snEGGqC3PqavHfmfbvwJyiXHt8/R5kfraUUd4hm3Rf1TikNVpnWTFOG3eMO4tcml4bmjgJAu4wGtAyLfosG/OaqQba4QRcXsF7HnH5yYcb04aEdFSmbSFIvm2OuGvc38KTXAsaFCZnvW3kIa/mMCyh5AGaE4srT9bmF/wti8CzqjpRDHRAEf4rqfYYTV+dRwBn1115rbEdQx1GDAwiuU/g9jqf9VhiPfaNRmcItnZflejWfi3LiPNFTcN7fSB3r1h4gg13dUb5C8wCRFc31cWNpERbzxRaneVP2iTQNDIuFcraf6aiEVAzWvGwv6SyLpuuWLL1sin3O2IrdZQRnO/VzaKnTxtqMVAFq+rxIA/511fK2+Gh970JUMXaJQZduiTv9aBar2q/raq7pvalrBqAWI9NgL+i9sO7Tf2stgti3W5FIsMUDQu54DzMpq5+mL6iEI37Peb4/jsFwDPicsM1mLRtXl3PiwZeXmURLUur2XdKaZB7SEhTYOhshIDKLR3Szn4d8INoZpN1RdHI+RikLISqMPlV09T32+I8sNRZ2V6+0ddqidtpyjUtsFrLNgo2gVBCepGBjrV16QlIpaJJBLzIZeq71GHJ8mc5zyYeJR95vgAecVEnMVmjNk1H1cL8C0lQFIlt+OsRAkokq5sF32OB/2YyDTFOHUVCvTB4P7VRHO45nU30hkCVGNha0yo5iyuCh4fFAjJbKh28BWyJ53F8kMNwwlsau/KSF2hJrcn4eGL7r2CvI6R/kLOJkVryEHHUb8xmc8HJhLKoscrCk26v0/LnYHH0Vui+3wmFsC9uATgVqURsuvzvzQkaX+qClec+F5YVJ52MUWF/b7BGCusWnQ9A5W2Jb/bdxHqKgPG4LaC8sdDwbgOCUWCTaOrEAuCfQUK+Nv7aH72BmdTLkMliOSnJWOZLz/Uubzagdy9sAuuqctFg3M3QTPi9Hgua2fch7XgGv+P+P8WF2AppwDIuEhKpzDPiJvtvKI1doOTtw==--LL+6EQr1zbYQIsEa--B0if+ehjZyS9BYxbPIbYKQ== \ No newline at end of file +lbv40Nq+bDCBc6DGVp3Q+CBuKLAt26v1vkKhOuUBJ/hPr07N98H13tejmM+C1GHF8DUJsTTsmSLYdYgelng0GDo9qhSpC/k+thZ1+Ne5pmZh00sH58PwItUNPBtLluqlpolRfNrJGMK3kjprWyTCbznAfGV6BZoT7Hbv12ZIri2bKsuTvsPZiIYLLAe2a2CWrNWjs4RezZbGWzbJpIyj+RIj3GKhGNCWNFhq79HyeRChaZNxioMaAgWBfAYKvXvkR2MuKEaspJM3lXnpnyxqVKWEWz4orhTCvlqbZchESDLF7hAuIkzXCXEC2EHpeEbzD0F52K/mLzT+kmi8dI+eLVimPJn0PZslccsHNAnXXHwvLvLOPFwzPz9N0jjGihu6yZKE1vl5r1dzo9mVWVTnsI5Z/g5SAmOtY+DB+pzssQdXSLFQZ/l2JcWvgYA9wCQyQbyGLEeirGe/QeCQciTkE+Z/bj7b+20rBgsRQIyHxNdzWDsmi4onJ75lIz6PA+1X4AIkwnAqUVzuBDwB+VxdwbzRZZeURMLBVzs76Ta/qSemIpuZ782cyt6MRmeYuAT6RMccnG/zwdvPHtpJ6C8fqKyOu+XkU2a49Yx/D6l/uvVtwiOayHTCwn2QmGYDlnniCBDJCzp0y0Y1M4d3XxAdV61XEL6rISMuM0mjdRZL3PYTpR8mrrflnYA4kFrLUnA+hEmcOq8stHSL1RYe83PI4dj6l1/hzQ6up2cGNBk+to7FXpQOhkuYV2+9xu+4PnJnl261G1D/v8zoWV+0OIn4ySC1o/t8BkvoYvFzDJkDIGYwNV6tTdhgitlZ3ljohwTj2HIclr0pZVwptQDJpe/ZYbpxq13uN0Xi6QdAWHJcUWzt3jZnX7N07nY34NW7DhM/D5SSDMkZbjPIPBS4t+n/lShh--MofA9sfR6Z6UuGQ2--jtVAHhYAxdvdXOJ/vb06Ug== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index cb2630e38..6dd50b314 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -mD7x3X/gvld0L+2qVY7VrjA10FcuFGSXkYW6Kw2gvx2MP9rwmTZduAnTrE4X6q8CQKVJA7kZhHj2Kkng1ukrJzDrSionpAp+YEQ+7dzpbO/nBfjyLDnPN17rOayrN7N79mQVwGHI57XAGT+mVUyZetxzaZMroPwPpt+1vtJnmLbTikMK7qqPfwJL8sqzoVPG2GE9qwB7x4Zo9wGtE5TyCHYUfNmz8wNlfgDKxtqajJsm8wTb+8/OSd5wVwCdulGtEYh6TGvrOgjCK9qn22hhtl8NZcAbhTBKr2tunkXirYRkPw9XDwvIGDAVL4ZwUZ7A7cRMGJ8Wyb/C7LHfTo49+Lj1EdRdBcyyz+DCpmXt7115JBClNS4vBeQ/t9KL9SepySeG38dsHfjJtZnu4bTP4IeT7S1Yqu/3e46Tqg6/2q8nPrS+FcSvLUy3baKi0RrgvBdE9JR5GCDmS3LCIu64f04cEzhFh735iyZrrsfyBVhOEh0hZIGc9xKvByDnnIH0pz/c+8l5LlyW8gqzRb7y1pg9W8qF1QK8kb7/UyiHovVWoXYwKZPNVy9qaDYUYqcXSpnopV85iTPWR49XJO2Q/TkX0vEgzOe9/REpRSbcvFCPkvh4rCScuqMlttVvnJF6JBHSbuXAkJL+yWZeqzc5LYQ8Os4i1VTPM2MIavw5ldU8I8knNY2wUz3dVbz9accJwf1v7fgSU3tBBRqsbuvpGIiUrJtnvCNWvtUYXBHgEcEabnAXTHO7Mg6pwdI9hSLlz9NeBefqmBb6xnv6bfx9C8P2dTdHCSaDWhh95fd/5hJBMCeGhnuZ7O2NrMYP0uUJfzdDj5JSY2p2+Tvw7iUeljh/Tua1g7yhLICTKkuu2g3G4vMcO7jcF9j5ibCbHgcGTUm9oghPbAINfg8JpdD0tA8aTQdLUdq5ue91PL/TUi3GgwzfCwUfxZFz1HjudVZyTQuIfuVWnap5r9CcEW4QjTDOreYNCqYd58XYj/Pm9LPiK9LcfYarbmPobUiUg63IW5jXfttCaAWToDWc3YuNU8NLTd3axXkF4NkS/GBRk18NqMQjbwXYR8eQctbQIEhnqANTkUwVp6qO3M//6nBTf3dp/dS18EmFgyGRm6cGYAsOyBNXyM1UCX6h9uG3scZPxACffEViZ0dNVJkOySVFFMEnSpY04CmVB7RtbAnBxleP//2bvJucffRVMFm8y42JzxCTC1qao2Es/IIj9epSK3l+YIGcW27QTr4x0I0swG5cOLxAQ2/HUdwZmCk1Oqjrtn4prHC/gl5+LIzsVmQ2dfcbZ5dyM/6IRyJNMQlknLv9FerCtURIzb/O0Lx9EfrQH18KP9KFPhgCMLsa0kt3JdYFfoAhFBrsB0Ioc4+F279F1j5+X5M+zCXkYq/71BndgCALwC7oVCXWFnl95jYA6yDTHMIr8fRjqV744wU9ZIIZPlaf2+duox9REOQBSnj+olkIHfh1AYHzFs7Sa9z3d2jBoplwzVqXbPjd4u882BinLjPy3UYPPnXtx7ND4MgsP5/NNY29OXUChr5OL658ayJADTdouXgIOghmfEKOacIn3zCiJbSJFH29jgS+MGIA5l6OxHkzj787MR7vC3mjF6Ut0f97LYVEMUshh92DhHao/oM4lcvdoQmhO5OI0zfhvA5vXhTvC2BI+CQjgRKrjLkCpEU8ZvHQ8ceO5hzpx/vf9v5tJVX8Q1EOZ7QklD4p4I9OVRWX3g7Sz4iUP5edwG3iVF7hAw==--mvpDxs3LkB232zsJ--tC39AAOxnHH12G7pzQ0nWw== \ No newline at end of file +z0tKJ9f/hCNXOi7b6lZlfzmFm8HAGNafiluMTcRlKmFxTm5ygs6fk0N0JNnJyrxsv6huye3/mptfrqkMOHRQcWzmZQyWDmayLxtmghRkpXmX92QZ8DiSs6jgWFX9zKuMPEW9eFpuLjstF1khPskg70SYVXBc3cTRJbyx5JGhnv1c3qOBfN4PKGYvTWsxacoQ356LYfu3haezmiS0yPzREo1rhFdwNyveV8uZ1QX1ZNZFp+WN5RXeiM632kL4R73KhwkbGBSHj5z9xxl0v4BzLrGM+Jf3VsJFmhYXS10mN+XOsNELCHUaM1wQOqMNHegBfupTzwur6Q3R154XP7rrBLquFT3wkAlkrI8ox+NRJgnGQ/yCc3PM8zhxgdrLzmr9s35eByKRCMsuIkF0WSfcBeWpCfNQaeucw10fuKirlI+G5JsX6HaRfAea3dUvVsuKCeutKn2/YPmw7Qodat2G9sZgUhBOYRzDcmzWaUyudciAMXRzNfoTZqngwFXDvfcAlmq4FrnucnfAsMB/TtqrZzV/td4nPfQLpxZCCl7v3hIggQUJl7gmdR335J6wJ4r8i0eTQjTw+j/Xm8wF1AEHpwxz2DOg8BjAI+/F8Lgqc+YDQCEYINYn+bwy5eMUv7H3aLDGrF3ZKnsEwWj62Q5LtHF/Fi7ZIOWgQyk4d5lLc4+T6hx4qe9iqu29LlduIxwJRG1dWPgNxuikPM2hcl6dQP8ebP9dNpdeeMCGylzR/kZjro7mAawxgIKEdOvWE6NJs8xGkjV2JjqWu7ufiIpk+jurUBlO5mIJuZhVvRumpMgcxVwqXWjJRt5BTVT/qcuLdaPcYY9hUt3TQrqjSGIt5bSFjcCFEfY15DxXsIVe84Oi4KYQNmuM4h2ub1zfYQYYH4UaGg7bLbb2OeQIIkkTwXXAR6o3BKJWtskOD3GTdamk3gKhudhm+BOgbOavoiE4hpvv/4ajjl8OGseGOAjC4bj8hpNnhezjQFRe2pK1515hi8iyYQ2tEScGy4wmsAbSaoA54+tHki0yeLUF8oJ9GTr9UCwnz8nUhQmfrS4VJO6Q/J4MXRCon+mAt3cyHaC6z3RISREw0a4aO3T2PGvUBVDEP0kfhDJmZXM5Of0RdFX27pwaQUPo3hqzQ+BmyenGqUonC1nPd/npojmRWXan9v5iMRKRx8qEBfhm+WQPEQuJqi5szm8pla6gUtNKaL6P4zhJOnIZiKxpYSlnezlMWNof/xcOb5BqYeEYQJ9FTvIxGrmN7OxQFlfk21z+n1OJI82TKIaKB/Oo+bsTtta8BuXONJUtsRSP9HcqMlMnwq2We4jn8JLFf/9O0s+iQtB6um7EAcHkE48jBHkMVzJVWB4/9Z8xe4DIwrg9BZH6UNL/pc/K5/dOeW8QoOPceBWmHP9BGoWFVqmNmEwPeNmnQZ5H/zxGPD94oG5YfiUMfMrOtIiAwS3Qqbmr+nAa5eNe4Ij7/i5nlV5gJ8y3NSNQvjico4iqBU1WAEHL6bHV--JcYezF+/1oGWkoz5--lepA3Pi+iuzJTgNRR4ax8Q== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc index 47b2e3af6..663b370e1 100644 --- a/config/credentials/staging.yml.enc +++ b/config/credentials/staging.yml.enc @@ -1 +1 @@ -hIHE2AaAwWrP9YzliIwVuq7iSyy81LNMmeScQYahZqbNW5pgdnprKwMVwp/R15mZrI/tNZI1utxC1FFX4BTsRgkYhVGrQDx74bB3EaT4JfEpPARpqMLYWMWqQ/xzuvhknoweR17Bty/E9gMgdw1Ip7pmQpZJRQVntSZIMxr+IysR7cbXm5Em9+LAxhm4ku7Zu5sviW/ljzOH/vJ7WvZzg7E7SpHNYV1cALbIjKvQKcBppR4tR0cXcUZ+7K9f5Qxp84XVWi5CA0IxLZ07Wq5QAPXN5/Cc8PkNZB9y7aRQ6lfaB+FzSHpE51I4u+zp9j5qDWU8+vAj5vskbDWn6t2KLWHPJRydhSo9u4Irh/VuzctL5JpSSfOJqmA/oCFnEEsrafcL+VMTUmTODLAnhMB31zOJvny57WN5oClFp/TdEoyvJy0bKkkMPHItNVoESKgyzdAmK57UyL19kyyuaJUw+qnL5CCOzSKDq6jd8ImH7AEGVOqrDEsVNQvBCPEh0qlxu1XY2FdsNdgSM86w6aLafX3DcWuawLg1CjzZDcc4GKCp36PjNqeTjy82biQ85kF5yXbLv/KjY4gO363jV9tnu15tQOc9BLaR12lbIerdUTvPQ2j1n/TReWIbmyUyZA/yoH1R0lz4XIl5yFe+jZ6AyyJ4SN4V/XNe8N3ejqKghuTuujfn/Eo5GxzMmGrgUn4GNtY1Z1p8vjAVb3Ime5gDDp1pvOqyPUlqotjRpn0V3aYbWoa1y85j+geQimNyAZT0lkbd4f4ExdsNgVmgd4iPCLiUuAfdH5b0w7IC4c8KgPfox91bKOQa86GytG7ch1h5+vyL/1fk4oXn/fj1vcszF+xzvYxmbZhh9DoYrxbZSzfjCZNK2I5DLeDZioWTO/HVVoUx1Gpp7hKR4iJxa7793m9BfsNz+2agdi8N62WNiXGM8hW1jPVKNnAD6PyMx4NlSe6vwyVBQA3ziBu0PxT7NCH43zqG2VpuQyV3GAmcSqGFDvbiqub9eIQOvBTylSE5KPEBTIdUF/JIe/mysKaMuaph3gOp8YT91xQ4DaT3DWYyjuRXhItWi5nPUbF5WGSXHPAAhXD/Qp0YNiDzClfIjinpqUF51FhfACCuMdwmKAf9almMdxcXKHvwHLpFJbepHMeuPMlbojNEvfwLUJi2IQ3YFQy5qLbu84aPTW9oSYZEJ0Rx2gOSB1wv3FNtcSAjDiq+plQQs4O1MxtmlcwOeHhwXGSVnnEbjabXJ92JI4yhReIDtEzXrSIaNu4HZtDdzQwe6KwyXoR2dxxcrhUXe2ImjxGLlhnaygdvr5DVQx8XfcOFCIHcSlxCXND+jg9EdBS9m9wW7PpG60+40BB1hjRkoTrzdW9R73m1E22CKOVlTzB8Am9rk1fmmxDyA8/SsErDo9J8LSmzCyQVomjgPfVtAXjcaME53UKL/Q92DLlJZv7koj7bSpN7XSH+ri50BrMCIURJuoivBgu6c8g51OcDMJCUYVajbws9W8hiA4t7dhRsC+1jh+0T/qfu4Kelo/RC5rLMc6de+Mmw0E3HIpTEBttJJJyiIpT0UDzoGcDpq+4Fa+zPRbxIPcD1NnfuhR/og6KsWvLeIsUfKDD+TI/gSE2zxYfC+/XaZO+8TRNc99Ep3qNJ5ZIy1hitn/wMUvCpuMUZAxjOlfh6CZekYgOFESkGKL+b0/O8xeqV7x8qE6SW7pTjCOoykD/jB4XNXY/M2RA4sEIgge0W8v4ENzafDsIFt1eQI/T8vGnbcUxvt7TaJdQ6hwanDKZ8kdmBrO2QG2+Y2vVhoxNYm06gjNAzygnjpYXyUtHLGGhAUjqITMpdXL/ZUnSzN/U8ib0s/p41Rlej472d3NXkbyKFW3E=--bQMkphrAKssxoUy8--J00s66sXG0GcHRoPPKmG7w== \ No newline at end of file +tTZW5futjROLhOOU4sULsj8f7nCQ73Z7Pmh/3HT2Y1hxhz7MxKOREq2ZjwEFPhL2/c/zim/C9PzgNql6MDjtUzYmd26Q+2HzFSS2JpFBqU7DnEgL6mWC7x5s9feTG61ilOhmlLIwZRgNvugwRAUTlCv2KZ8R5iaUviPcwf6fl4dOhM7pW2Fx8MhuJQBSbjkoqisyniIzdCvgsE2s4qIkxBz+qrbjVNhmyTvl/jdttjTcIYlubCOlELZdbT4zNsitcs/8xBTBlxsR4Vt9rBcwr+EPrhQfpvGVKnSleeiatc6EOi0kAniqbjwJFkcpJ95PrU3yhV8TP7RAzHpOIhYHSwDcrQS2ltP9z7fVP2CRicpWEIh7iFR79JtIsp1cYH6pbx08ZAnKKeu240SKkKA/wZH5BzyOrVaTHwQ5q18U8hzWon6loMiQMV9+/jRZtb8rOTHU4IpUi/oM7shUZoHxccBq68ZxpclLEDZs7CLNytXbRq9oaCqyg5tW5qW+Lws+4iRlmR43hTq2SHJ+eMjq4N8WxAdecDGbgPdNFaoY6Ymw9YEwsYiWzHx7fRC0EUYckbUPEccT6b8IuYTJg/QeQQ8HVenboM9U/9Cd6SODR4Vdyn6fGkFmc7mfPke01naCUEconH7RL0EB38yz/MMqW355PhsaZqsv7PlCE7KJIxkejp3gwiBwP8petq4OazxZY2MJahYLwvMQwFt35qu9RJVAMiAMN9DLvRQu7w+oNA0g3OSGOwnIzHtjKMRks3W4YNEgwMGrq3YzI11EmORB/1F3t+UDDN8GOxxGgBmIdVrFHwSazrw5igqv/kTFsrzxSHW1ja3/U27JVYOSGDBMZ5TlAH7CSFD6SgbDHreauN/O0zuesTH9QnkUARhOHw75dfTEvacjy6uIcO9GmrRConJGWzJdZj5joBROhCtoflcn9jfce3W6YJfzHgveUxPnRU1bhaNULGTW/lA+cclDmm1jL2AhpDYzk1hq6/I5we0fY9/VNMgFnm+c/qrkjrXChFNCT4w+EU+n7TaXJ5ocWdCMTyM2/RSnm1dBX8wGoygryOYEnHM+6GDeX64vfGSnWZ2yfpsKrAzn7zVFWAY6Ah2YEXm+pQdUPB4CYwvg6A1liaCvF71P2p9SZgX79YEwGvsODMzrBavxVMIlVa92jtOZ23yoOjfR8gWleqeyoAOCN2JaM4rJfPrzZrs5LKDfYtgpR4Jg36LjD1UKWp3Kab+kHOpxzkPOgq6v8px3Rk3xaGADwCPnE+4qSTFByYaHCdx6FNeQQit3jSEq4h6WNeVkrKnZ6ybCnd7LwBKiu9uQURB6jar7t4IqM6kYThN9J7q/oxp3HS5+TkNT+1Org13CuNL9iF3M6n+R/U/vJCCRUMbM9+O8cYHEt5MJ875qBD1NRxj4UbVCcv5rDXhAFq+GuJqJTLN2+NPWVJ2QhZk1bhmwxIsZJ5vlWTMUXoOZOxUJrLqPK+v8h2x6G8J8Cc0WpzsHAl1B8peN7TxXJgcOyaVkHbPr2CYao/z7mG7D+5SIHcqcbNUB09Nh4iMVbQa1SEpTnWPqum1EwXkmH8QjUcj+IMsXcNWTVkaAfAsme50fMS8XGLiOU9AGfV5QcryaJQ6w250ewQ==--IKMr2eXgh2p/VO+z--2sCRrfBLg9TEjHH93qFmcw== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index c698d932e..5374a7f13 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -GmilAzR45j/MIl29ZP7M7NkyjMrAKvQ3s4He/enEUhetB3IHVS+EotbU1RW5Zvaeq8a0NFHW8BFdF+DQ0MU25s5Tn8aDWe+GG/7HlN1O7r00Wf8bOgyCRPJAUmkkP1gMwLtS4VCFseMXancAKyOhh7MXEs9GLoj7IxczxOwo6wAmWvQQMiFewo1nWzXQIa6kEe7MGixJpmJl+FYdDmHHxNZ74le6qW3zTl5lLhCi5aC4ntfioOQPBX5psJ4yPixSaIAPucjV9pjaPaXORONtBsTp3CUA8FeLh+/lxA26JNqHR5PP0BA+KyGjJNGlK/yS33F7UsExyUBD58UADbaoeBsarXSyLP3OWhT40UgPZvkvQP7MoVsJurxjYCVIzYLMcwAqWFV1NakWp/aJqAENopD0XT0ODh1eslUN7kYYMvYg03IHevOkvz3UPhx2L/s/tyqwa3D8YGzVFxEq9Y6aMDelhJ1/T/0+8FpTMnvGQt+rXfCTzPr2AeQDO1OnLFtiksFL8AqDuKKXh6OspY2z+TgSpCR+6uqIaauh4attlmoWlG5bvwKPuCdJ4hxdaYT+bLcoqN736j7db2RGF8xNauiCxYeoqZtg8MN24geNosBnIqEIQmrejZ/UCZtFWVDhtnw/xYrjDu6/3PAxTWfTqNc/WCXrMSrO9E3upAEQKSiveWaPWFdf/opW75IQP1FSLnZ2IAHm4HH1zvvdO5aL4wwB76sGxq7UrNZIBrCsKSAemIJKimuUoZ5mdE/9ZyzeK00dDvuQ/NUulQLEfa1PcQOjvd7YErZJsiaP4BF8/d5pBv3y68rpxsHp1bl2hMkqS/ww+DpYhB37OiSpdxDSR8e7MOE=--L0z/iKlmgeOYpAtv--SJvijuKNOO/M1jGTEdzxFg== \ No newline at end of file +OCWdPtq1nxi6erX3KVmolRjjazqRbSQ102v9t/MUwHy2Af7CztQt+S0N2fGBNnGb0QTNGbzAlETRVUo7trc7+pjO8w7lyuHS/eKO84wuuPh7qRkrZfwxF2R0D2jmcaCa2S2Hfs8Ws7xFN2j9xCdjWJKd4khP4OygyLFK9wah9fWW+QvMl1evgnSA8Vtko1Eh+/bBQC4Lb3/tlsjeqfv/N229oPywD6uM6XfRsSS0/tuCp3w1MPhs2lb1ZfTwt4y5Dsk+r0YoUUr0CDPUAPSohmDJn5++8NiipIt7CQoGe72M4dp7WZ4eQAiprID8QIWAQ2I8RdvwbJdjFRKjcjgeB7vYiLUTRgmav8q/7QDk+JxWCU5oQxDmVex63CJ+sZ8NI94EnyppqHqihyLDzViwbBHkOmWj3WsgPOQHNjwhX+LwMeJ8UkWjAJlA7q59EqEDvPPN7MAgpunPuRXL6YXsS6UBpUmpof6fwMyhHbnFpRgTx0QyUFW3Ta7cs2JWCST2PXI5lGQKJBKIhcLB60bO1NtJYtX3xlheywnbNUzvsBTQO3j+ezPxcy07REvW1NfXQInpAgOudsZ4ScW0mblwiO5v/rLGJN5cMUNWAg==--8NVEpFCH347bJZoD--6fw4f/Fad/N8XQH6oTwpQg== \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 25207bde7..45dc01625 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -67,7 +67,4 @@ Rails.application.configure do # Load test helpers config.autoload_paths += %w[ test/test_helpers ] - - # Auto-encrypt test fixture attributes - config.active_record.encryption.encrypt_fixtures = true end diff --git a/test/fixtures/webhook/deliveries.yml b/test/fixtures/webhook/deliveries.yml index bc0464141..ffe55d9ad 100644 --- a/test/fixtures/webhook/deliveries.yml +++ b/test/fixtures/webhook/deliveries.yml @@ -4,32 +4,24 @@ successfully_completed: webhook: active event: logo_published state: completed - request: - headers: {} - response: - code: 200 - headers: {} + request: '<%= { headers: {} }.to_json %>' + response: '<%= { code: 200, headers: {} }.to_json %>' created_at: <%= 1.week.ago %> unsuccessfully_completed: webhook: active event: logo_assignment_jz state: completed - request: - headers: {} - response: - code: 422 - headers: {} + request: '<%= { headers: {} }.to_json %>' + response: '<%= { code: 422, headers: {} }.to_json %>' created_at: <%= 1.week.ago + 1.hour %> errored: webhook: active event: layout_published state: errored - request: - headers: {} - response: - error: "destination_unreachable" + request: '<%= { headers: {} }.to_json %>' + response: '<%= { error: "destination_unreachable" }.to_json %>' created_at: <%= 1.week.ago %> pending: diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index a45c89059..b882559a7 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -51,7 +51,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url) - .to_return(status: 200, headers: { "content-type" => "application/json", "x-test" => "foo" }) + .to_return(status: 200, headers: { "content-type" => "application/json" }) assert_equal "pending", delivery.state @@ -63,7 +63,6 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert delivery.persisted? assert_equal "completed", delivery.state assert delivery.request[:headers].present? - assert_equal [ "foo" ], delivery.response[:headers]["x-test"] assert_equal 200, delivery.response[:code] assert delivery.response[:error].blank? end From fee25a49ea76fcaeda895c6535a9445d30142e70 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 17:35:05 +0200 Subject: [PATCH 18/31] Periodically cleanup deliveries --- app/jobs/webhook/cleanup_deliveries_job.rb | 7 +++++++ app/models/webhook/delivery.rb | 6 ++++++ config/recurring.yml | 3 +++ test/models/webhook/delivery_test.rb | 13 +++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 app/jobs/webhook/cleanup_deliveries_job.rb diff --git a/app/jobs/webhook/cleanup_deliveries_job.rb b/app/jobs/webhook/cleanup_deliveries_job.rb new file mode 100644 index 000000000..63f87ead5 --- /dev/null +++ b/app/jobs/webhook/cleanup_deliveries_job.rb @@ -0,0 +1,7 @@ +class Webhook::CleanupDeliveriesJob < ApplicationJob + def perform + ApplicationRecord.with_each_tenant do + Webhook::Delivery.cleanup + end + end +end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 3624a2d0b..780d6772d 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -1,4 +1,5 @@ class Webhook::Delivery < ApplicationRecord + STALE_TRESHOLD = 7.days USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 @@ -18,9 +19,14 @@ class Webhook::Delivery < ApplicationRecord 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 diff --git a/config/recurring.yml b/config/recurring.yml index 45410859d..d6d68ae88 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -26,6 +26,9 @@ production: &production generate_weekly_highlights: command: "User.generate_all_weekly_highlights_later" schedule: every sunday at noon + cleanup_webhook_deliveries: + command: "Webhook::CleanupDeliveriesJob" + schedule: every 4 hours beta: *production staging: *production diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index b882559a7..8de7b6713 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -190,4 +190,17 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_requested request_stub assert delivery.succeeded? end + + test "cleanup" do + webhook = webhooks(:active) + event = events(:layout_commented) + + fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event) + stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago) + + Webhook::Delivery.cleanup + + assert Webhook::Delivery.exists?(fresh_delivery.id) + assert_not Webhook::Delivery.exists?(stale_delivery.id) + end end From 1d192c9d4249086474dba37bb90cbff54a1e3165 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 18:14:09 +0200 Subject: [PATCH 19/31] Ignore low-volume webhooks for delinquency checks --- app/models/webhook/delinquency_tracker.rb | 28 ++++++++----------- app/models/webhook/delivery.rb | 4 +++ .../webhook/delinquency_tracker_test.rb | 4 +-- test/models/webhook/delivery_test.rb | 13 +++++++-- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb index cf250e373..f3eafc06e 100644 --- a/app/models/webhook/delinquency_tracker.rb +++ b/app/models/webhook/delinquency_tracker.rb @@ -1,39 +1,35 @@ class Webhook::DelinquencyTracker < ApplicationRecord - RESET_INTERVAL = 12.hours - MINIMUM_DELIVERIES = 50 + LOW_VOLUME_TRESHOLD = 10 + RESET_INTERVAL = 1.hour belongs_to :webhook before_validation { self.last_reset_at ||= Time.current } def record_delivery_of(delivery) - if reset_due? + if delivery.failed? && high_volume? && reset_due? webhook.deactivate if delinquent? reset else increment!(:total_count) - increment!(:failed_count) unless delivery.succeeded? + increment!(:failed_count) if delivery.failed? end end private + def high_volume? + total_count > LOW_VOLUME_TRESHOLD + end + def delinquent? - significantly_active? && all_deliveries_failed? - end - - def significantly_active? - total_count >= MINIMUM_DELIVERIES - end - - def all_deliveries_failed? failed_count == total_count end - def reset - update_columns total_count: 0, failed_count: 0, last_reset_at: Time.current - end - def reset_due? last_reset_at.before?(RESET_INTERVAL.ago) end + + def reset + update_columns total_count: 0, failed_count: 0, last_reset_at: Time.current + end end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 780d6772d..0108a44a0 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -45,6 +45,10 @@ class Webhook::Delivery < ApplicationRecord raise end + def failed? + (errored? || completed?) && !succeeded? + end + def succeeded? completed? && response[:error].blank? && response[:code].between?(200, 299) end diff --git a/test/models/webhook/delinquency_tracker_test.rb b/test/models/webhook/delinquency_tracker_test.rb index 8e24ab421..80c55c2ba 100644 --- a/test/models/webhook/delinquency_tracker_test.rb +++ b/test/models/webhook/delinquency_tracker_test.rb @@ -20,9 +20,9 @@ class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase end travel_to 13.hours.from_now do - tracker.update!(total_count: 10, failed_count: 5) + tracker.update!(total_count: 11, failed_count: 5) - tracker.record_delivery_of(successful_delivery) + tracker.record_delivery_of(failed_delivery) tracker.reload assert_equal 0, tracker.total_count diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 8de7b6713..3be700b3d 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -57,7 +57,9 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase tracker = delivery.webhook.delinquency_tracker assert_difference -> { tracker.reload.total_count }, 1 do - delivery.deliver + assert_no_difference -> { tracker.reload.failed_count } do + delivery.deliver + end end assert delivery.persisted? @@ -65,16 +67,23 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert delivery.request[:headers].present? assert_equal 200, delivery.response[:code] assert delivery.response[:error].blank? + assert delivery.succeeded? end test "deliver when the network timeouts" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url).to_timeout - delivery.deliver + tracker = delivery.webhook.delinquency_tracker + assert_difference -> { tracker.reload.total_count }, 1 do + assert_difference -> { tracker.reload.failed_count }, 1 do + delivery.deliver + end + end assert_equal "completed", delivery.state assert_equal "connection_timeout", delivery.response[:error] + assert delivery.failed? end test "deliver when the connection is refused" do From 25b2daad93cf9953e52534927b2b7c34e7c2479b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Sep 2025 19:16:39 +0200 Subject: [PATCH 20/31] Limit access to webhooks to admins --- app/controllers/admin_controller.rb | 2 +- app/controllers/application_controller.rb | 2 +- app/controllers/concerns/authorization.rb | 10 ++++++++++ app/controllers/concerns/staff_only.rb | 12 ------------ app/controllers/conversations/messages_controller.rb | 3 +-- app/controllers/conversations_controller.rb | 2 +- app/controllers/webhooks/activations_controller.rb | 1 + app/controllers/webhooks_controller.rb | 1 + app/models/user/role.rb | 2 +- app/views/account/settings/show.html.erb | 9 ++++++--- 10 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 app/controllers/concerns/authorization.rb delete mode 100644 app/controllers/concerns/staff_only.rb diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 60f2b01e1..4e7e52372 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -1,3 +1,3 @@ class AdminController < ApplicationController - include StaffOnly + before_action :ensure_is_staff_member end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6abefad23..aa35df0b3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity + include Authentication, Authorization, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity stale_when_importmap_changes allow_browser versions: :modern, block: -> { render "errors/not_acceptable", layout: "error" } diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 000000000..38873b098 --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,10 @@ +module Authorization + private + def ensure_can_administer + head :forbidden unless Current.user.admin? + end + + def ensure_is_staff_member + head :forbidden unless Current.user.staff? + end +end diff --git a/app/controllers/concerns/staff_only.rb b/app/controllers/concerns/staff_only.rb deleted file mode 100644 index e8c34ffda..000000000 --- a/app/controllers/concerns/staff_only.rb +++ /dev/null @@ -1,12 +0,0 @@ -module StaffOnly - extend ActiveSupport::Concern - - included do - before_action :ensure_staff - end - - private - def ensure_staff - head :forbidden unless Current.user.staff? - end -end diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb index c69b1e684..d37ac040b 100644 --- a/app/controllers/conversations/messages_controller.rb +++ b/app/controllers/conversations/messages_controller.rb @@ -1,6 +1,5 @@ class Conversations::MessagesController < ApplicationController - include StaffOnly - + before_action :ensure_is_staff_member before_action :set_conversation def index diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb index 69de90d03..2e100fecf 100644 --- a/app/controllers/conversations_controller.rb +++ b/app/controllers/conversations_controller.rb @@ -1,5 +1,5 @@ class ConversationsController < ApplicationController - include StaffOnly + before_action :ensure_is_staff_member def create Current.user.start_or_continue_conversation diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb index bc72998f2..ea30e1e87 100644 --- a/app/controllers/webhooks/activations_controller.rb +++ b/app/controllers/webhooks/activations_controller.rb @@ -1,4 +1,5 @@ class Webhooks::ActivationsController < ApplicationController + before_action :ensure_can_administer before_action :set_webhook def create diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index c40fc72c2..c65f37532 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,6 +1,7 @@ class WebhooksController < ApplicationController include FilterScoped + before_action :ensure_can_administer before_action :set_webhook, except: %i[ index new create ] def index diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 4a90410b5..aaa4bd5ff 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -18,7 +18,7 @@ module User::Role admin? || other == self end - def can_administer?(other) + def can_administer?(other = nil) admin? && other != self end end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 6ab7e3bb7..492ee6cb4 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -4,10 +4,13 @@ <%= render "filters/menu", user_filtering: @user_filtering %>

    <%= @page_title %>

    - <%= link_to webhooks_path, class: "btn" do %> - <%= icon_tag "world" %> - Webhooks + <% if Current.user.admin? %> + <%= link_to webhooks_path, class: "btn" do %> + <%= icon_tag "world" %> + Webhooks + <% end %> <% end %> + <%= link_to workflows_path, class: "btn" do %> <%= icon_tag "bolt" %> Workflows From 02a421675f74dae97c1ec32f012d148a0467ba76 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Sep 2025 16:05:36 +0200 Subject: [PATCH 21/31] Scope Webhooks to Collections --- app/controllers/webhooks_controller.rb | 19 +++++--- app/models/collection.rb | 1 + app/models/webhook.rb | 2 + app/models/webhook/triggerable.rb | 2 +- app/views/account/settings/show.html.erb | 7 --- app/views/cards/_webhooks.html.erb | 4 ++ app/views/cards/index.html.erb | 1 + app/views/webhooks/edit.html.erb | 2 +- app/views/webhooks/index.html.erb | 11 ++++- app/views/webhooks/new.html.erb | 4 +- app/views/webhooks/show.html.erb | 6 +-- config/routes.rb | 16 ++++--- ...913150256_add_collection_id_to_webhooks.rb | 5 ++ db/schema.rb | 3 ++ db/schema_cache.yml | 19 +++++++- .../webhooks/activations_controller_test.rb | 4 +- test/controllers/webhooks_controller_test.rb | 32 +++++++------ test/fixtures/webhooks.yml | 2 + test/models/webhook/delivery_test.rb | 4 ++ test/models/webhook_test.rb | 46 +++++++++---------- 20 files changed, 122 insertions(+), 68 deletions(-) create mode 100644 app/views/cards/_webhooks.html.erb create mode 100644 db/migrate/20250913150256_add_collection_id_to_webhooks.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index c65f37532..ddd0f09da 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -2,21 +2,22 @@ class WebhooksController < ApplicationController include FilterScoped before_action :ensure_can_administer + before_action :set_collection before_action :set_webhook, except: %i[ index new create ] def index - set_page_and_extract_portion_from Webhook.all.ordered + set_page_and_extract_portion_from @collection.webhooks.ordered end def show end def new - @webhook = Webhook.new + @webhook = @collection.webhooks.new end def create - @webhook = Webhook.new(webhook_params) + @webhook = @collection.webhooks.new(webhook_params) if @webhook.save redirect_to @webhook @@ -38,15 +39,21 @@ class WebhooksController < ApplicationController def destroy @webhook.destroy! - redirect_to webhooks_path, status: :see_other + redirect_to collection_webhooks_path, status: :see_other end private + def set_collection + @collection = Collection.find(params[:collection_id]) + end + def set_webhook - @webhook = Webhook.find(params[:id]) + @webhook = @collection.webhooks.find(params[:id]) end def webhook_params - params.expect webhook: [ :name, :url, subscribed_actions: [] ] + params + .expect(webhook: [ :name, :url, subscribed_actions: [] ]) + .merge(collection_id: @collection.id) end end diff --git a/app/models/collection.rb b/app/models/collection.rb index 4723ccd64..0d8fdeb37 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -8,6 +8,7 @@ class Collection < ApplicationRecord has_many :cards, dependent: :destroy has_many :tags, -> { distinct }, through: :cards has_many :events + has_many :webhooks, dependent: :destroy has_one :entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy scope :alphabetically, -> { order("lower(name)") } diff --git a/app/models/webhook.rb b/app/models/webhook.rb index defb57186..9fbdada34 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -26,6 +26,8 @@ class Webhook < ApplicationRecord has_many :deliveries, dependent: :delete_all has_one :delinquency_tracker, dependent: :delete + belongs_to :collection + serialize :subscribed_actions, type: Array, coder: JSON scope :ordered, -> { order(name: :asc, id: :desc) } diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb index 4e8afeb43..a2f5ea186 100644 --- a/app/models/webhook/triggerable.rb +++ b/app/models/webhook/triggerable.rb @@ -2,7 +2,7 @@ module Webhook::Triggerable extend ActiveSupport::Concern included do - scope :triggered_by, ->(event) { triggered_by_action(event.action) } + scope :triggered_by, ->(event) { where(collection: event.collection).triggered_by_action(event.action) } scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 492ee6cb4..6d41d439d 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -4,13 +4,6 @@ <%= render "filters/menu", user_filtering: @user_filtering %>

    <%= @page_title %>

    - <% if Current.user.admin? %> - <%= link_to webhooks_path, class: "btn" do %> - <%= icon_tag "world" %> - Webhooks - <% end %> - <% end %> - <%= link_to workflows_path, class: "btn" do %> <%= icon_tag "bolt" %> Workflows diff --git a/app/views/cards/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb new file mode 100644 index 000000000..bc18b0bc6 --- /dev/null +++ b/app/views/cards/_webhooks.html.erb @@ -0,0 +1,4 @@ +<%= link_to collection_webhooks_path(collection_id: collection), class: "btn tooltip" do %> + <%= icon_tag "world" %> + Webhooks for <%= collection.name %> +<% end %> diff --git a/app/views/cards/index.html.erb b/app/views/cards/index.html.erb index 59128e22e..24161c57a 100644 --- a/app/views/cards/index.html.erb +++ b/app/views/cards/index.html.erb @@ -20,6 +20,7 @@
    <% if collection = @filter.single_collection %> + <%= render "cards/webhooks", collection: collection if Current.user.admin? %> <%= render "cards/collection_settings", collection: collection %> <% end %>
    diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index ceba7b793..c6358248d 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -20,6 +20,6 @@ Update Webhook <% end %> - <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
    diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb index 1b835634f..3f2c47e19 100644 --- a/app/views/webhooks/index.html.erb +++ b/app/views/webhooks/index.html.erb @@ -2,10 +2,15 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> -

    <%= @page_title %>

    + <%= link_to cards_path(collection_ids: [ @collection ]), class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + <%= @collection.name %> + + <% end %>
    - <%= link_to new_webhook_path, class: "btn" do %> + <%= link_to new_collection_webhook_path, class: "btn" do %> <%= icon_tag "add" %> Create a new webhook <% end %> @@ -19,6 +24,8 @@ navigable_list_focus_on_selection_value: true, navigable_list_actionable_items_value: true } do %> +

    <%= @page_title %>

    +
      <%= render partial: "webhooks/webhook", collection: @page.records %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 996e2e3ea..98c5642f7 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -6,7 +6,7 @@ <% end %>
      - <%= form_with model: @webhook, url: webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> + <%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %>

      <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

      @@ -22,6 +22,6 @@ Create Webhook <% end %> - <%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %> + <%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
      diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index 1926b2cd8..a14cefe85 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -3,7 +3,7 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> - <%= link_to webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + <%= link_to collection_webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> Webhooks @@ -13,7 +13,7 @@
      - <%= link_to edit_webhook_path(@webhook), class: "user-edit-link btn" do %> + <%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "user-edit-link btn" do %> <%= icon_tag "pencil" %> Edit <% end %> @@ -28,7 +28,7 @@ <% unless @webhook.active? %>
      - <%= button_to "Reactivate", webhook_activation_path(@webhook), method: :post %> + <%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
      <% end %> diff --git a/config/routes.rb b/config/routes.rb index 15262d129..608b2aafd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,12 @@ Rails.application.routes.draw do end resources :cards, only: %i[ create ] + + resources :webhooks do + scope module: "webhooks" do + resource :activation, only: %i[ create ] + end + end end namespace :cards do @@ -86,12 +92,6 @@ Rails.application.routes.draw do resources :stages, module: :workflows end - resources :webhooks do - scope module: "webhooks" do - resource :activation, only: %i[ create ] - end - end - resources :uploads, only: :create get "/u/*slug" => "uploads#show", as: :upload @@ -176,6 +176,10 @@ Rails.application.routes.draw do polymorphic_url(event.eventable, options) end + resolve "Webhook" do |webhook, options| + route_for :collection_webhook, webhook.collection, webhook, options + end + get "up", to: "rails/health#show", as: :rails_health_check get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" diff --git a/db/migrate/20250913150256_add_collection_id_to_webhooks.rb b/db/migrate/20250913150256_add_collection_id_to_webhooks.rb new file mode 100644 index 000000000..871c44c83 --- /dev/null +++ b/db/migrate/20250913150256_add_collection_id_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddCollectionIdToWebhooks < ActiveRecord::Migration[8.1] + def change + add_reference :webhooks, :collection, null: false, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0a1759a5e..319f33939 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -479,12 +479,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do create_table "webhooks", force: :cascade do |t| t.boolean "active", default: true, null: false + t.integer "collection_id", null: false t.datetime "created_at", null: false t.string "name" t.string "signing_secret", null: false t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false + t.index ["collection_id"], name: "index_webhooks_on_collection_id" end create_table "workflow_stages", force: :cascade do |t| @@ -535,6 +537,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do add_foreign_key "webhook_delinquency_trackers", "webhooks" add_foreign_key "webhook_deliveries", "events" add_foreign_key "webhook_deliveries", "webhooks" + add_foreign_key "webhooks", "collections" add_foreign_key "workflow_stages", "workflows" # Virtual tables defined in this database. diff --git a/db/schema_cache.yml b/db/schema_cache.yml index e390ad7ea..3b8395663 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1409,6 +1409,7 @@ columns: - *37 webhooks: - *38 + - *24 - *5 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -3165,7 +3166,23 @@ indexes: nulls_not_distinct: comment: valid: true - webhooks: [] + webhooks: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: webhooks + name: index_webhooks_on_collection_id + unique: false + columns: + - collection_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true workflow_stages: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: workflow_stages diff --git a/test/controllers/webhooks/activations_controller_test.rb b/test/controllers/webhooks/activations_controller_test.rb index 228382a64..0144f0bd9 100644 --- a/test/controllers/webhooks/activations_controller_test.rb +++ b/test/controllers/webhooks/activations_controller_test.rb @@ -11,9 +11,9 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest assert_not webhook.active? assert_changes -> { webhook.reload.active? }, from: false, to: true do - post webhook_activation_path(webhook) + post collection_webhook_activation_path(webhook.collection, webhook) end - assert_redirected_to webhook_path(webhook) + assert_redirected_to collection_webhook_path(webhook.collection, webhook) end end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index dbfab6e79..aa8696fa5 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -6,25 +6,27 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest end test "index" do - get webhooks_path + get collection_webhooks_path(collections(:writebook)) assert_response :success end test "show" do webhook = webhooks(:active) - get webhook_path(webhook) + get collection_webhook_path(webhook.collection, webhook) assert_response :success end test "new" do - get new_webhook_path + get new_collection_webhook_path(collections(:writebook)) assert_response :success assert_select "form" end test "create with valid params" do + collection = collections(:writebook) + assert_difference "Webhook.count", 1 do - post webhooks_path, params: { + post collection_webhooks_path(collection), params: { webhook: { name: "Test Webhook", url: "https://example.com/webhook", @@ -35,15 +37,17 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest webhook = Webhook.order(id: :desc).first - assert_redirected_to webhook_path(webhook) + assert_redirected_to collection_webhook_path(webhook.collection, webhook) + assert_equal collection, webhook.collection assert_equal "Test Webhook", webhook.name assert_equal "https://example.com/webhook", webhook.url assert_equal [ "card_created", "card_closed" ], webhook.subscribed_actions end test "create with invalid params" do + collection = collections(:writebook) assert_no_difference "Webhook.count" do - post webhooks_path, params: { + post collection_webhooks_path(collection), params: { webhook: { name: "", url: "invalid-url" @@ -57,14 +61,14 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest test "edit" do webhook = webhooks(:active) - get edit_webhook_path(webhook) + get edit_collection_webhook_path(webhook.collection, webhook) assert_response :success assert_select "form" end test "update with valid params" do webhook = webhooks(:active) - patch webhook_path(webhook), params: { + patch collection_webhook_path(webhook.collection, webhook), params: { webhook: { name: "Updated Webhook", subscribed_actions: [ "card_created" ] @@ -73,14 +77,14 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest webhook.reload - assert_redirected_to webhook_path(webhook) + assert_redirected_to collection_webhook_path(webhook.collection, webhook) assert_equal "Updated Webhook", webhook.name assert_equal [ "card_created" ], webhook.subscribed_actions end test "update with invalid params" do webhook = webhooks(:active) - patch webhook_path(webhook), params: { + patch collection_webhook_path(webhook.collection, webhook), params: { webhook: { name: "" } @@ -90,7 +94,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest assert_select "form" assert_no_changes -> { webhook.reload.url } do - patch webhook_path(webhook), params: { + patch collection_webhook_path(webhook.collection, webhook), params: { webhook: { name: "Updated Webhook", url: "https://different.com/webhook" @@ -98,16 +102,16 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest } end - assert_redirected_to webhook_path(webhook) + assert_redirected_to collection_webhook_path(webhook.collection, webhook) end test "destroy" do webhook = webhooks(:active) assert_difference "Webhook.count", -1 do - delete webhook_path(webhook) + delete collection_webhook_path(webhook.collection, webhook) end - assert_redirected_to webhooks_path + assert_redirected_to collection_webhooks_path(webhook.collection) end end diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index 4317606fe..4184eb577 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -4,6 +4,7 @@ active: url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' + collection: writebook inactive: active: false @@ -11,3 +12,4 @@ inactive: url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' + collection: private diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 3be700b3d..d79713717 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -119,6 +119,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase test "deliver with basecamp webhook format" do webhook = Webhook.create!( + collection: collections(:writebook), name: "Basecamp", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" ) @@ -140,6 +141,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase test "deliver with campfire webhook format" do webhook = Webhook.create!( + collection: collections(:writebook), name: "Campfire", url: "https://example.com/rooms/123/456-room-name/messages" ) @@ -160,6 +162,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase test "deliver with slack webhook format" do webhook = Webhook.create!( + collection: collections(:writebook), name: "Slack", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" ) @@ -181,6 +184,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase test "deliver with generic webhook format" do webhook = Webhook.create!( + collection: collections(:writebook), name: "Generic", url: "https://example.com/webhook" ) diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb index ac2ddc748..fe5a92cac 100644 --- a/test/models/webhook_test.rb +++ b/test/models/webhook_test.rb @@ -2,7 +2,7 @@ require "test_helper" class WebhookTest < ActiveSupport::TestCase test "create" do - webhook = Webhook.create! name: "Test", url: "https://example.com/webhook" + webhook = Webhook.create! name: "Test", url: "https://example.com/webhook", collection: collections(:writebook) assert webhook.persisted? assert webhook.active? assert webhook.signing_secret.present? @@ -10,30 +10,30 @@ class WebhookTest < ActiveSupport::TestCase end test "validates the url" do - webhook = Webhook.new name: "Test" + webhook = Webhook.new name: "Test", collection: collections(:writebook) assert_not webhook.valid? assert_includes webhook.errors[:url], "not a URL" - webhook = Webhook.new name: "Test", url: "not a url" + webhook = Webhook.new name: "Test", collection: collections(:writebook), url: "not a url" assert_not webhook.valid? assert_includes webhook.errors[:url], "not a URL" - webhook = Webhook.new name: "NOTHING", url: "example.com/webhook" + webhook = Webhook.new name: "NOTHING", collection: collections(:writebook), url: "example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" - webhook = Webhook.new name: "BLANK", url: "//example.com/webhook" + webhook = Webhook.new name: "BLANK", collection: collections(:writebook), url: "//example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" - webhook = Webhook.new name: "GOPHER", url: "gopher://example.com/webhook" + webhook = Webhook.new name: "GOPHER", collection: collections(:writebook), url: "gopher://example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" - webhook = Webhook.new name: "HTTP", url: "http://example.com/webhook" + webhook = Webhook.new name: "HTTP", collection: collections(:writebook), url: "http://example.com/webhook" assert webhook.valid? - webhook = Webhook.new name: "HTTPS", url: "https://example.com/webhook" + webhook = Webhook.new name: "HTTPS", collection: collections(:writebook), url: "https://example.com/webhook" assert webhook.valid? end @@ -54,53 +54,53 @@ class WebhookTest < ActiveSupport::TestCase end test "for_slack?" do - webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" + webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" assert webhook.for_slack? - webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678" + webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678" assert_not webhook.for_slack? - webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678" + webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678" assert_not webhook.for_slack? - webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/" + webhook = Webhook.new url: "https://hooks.slack.com/services/" assert_not webhook.for_slack? - webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_slack? end test "for_campfire?" do - webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/messages" + webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/messages" assert webhook.for_campfire? - webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123-test-room/messages" + webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123-test-room/messages" assert webhook.for_campfire? - webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123/messages" + webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123/messages" assert_not webhook.for_campfire?, "The bot key is missing a token" - webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_campfire? - webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/messages" + webhook = Webhook.new url: "https://example.com/rooms/123/messages" assert_not webhook.for_campfire? - webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/" + webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/" assert_not webhook.for_campfire? end test "for_basecamp?" do - webhook = Webhook.new name: "Test", url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines" + webhook = Webhook.new url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines" assert webhook.for_basecamp? - webhook = Webhook.new name: "Test", url: "https://example.com/webhook" + webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_basecamp? - webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/" + webhook = Webhook.new url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/" assert_not webhook.for_basecamp? - webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines" + webhook = Webhook.new url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines" assert_not webhook.for_basecamp? end end From dd3063b01df2a7d2a25f9a1a5be82d5532683d33 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Sep 2025 17:03:32 +0200 Subject: [PATCH 22/31] Simplify delinquency tracking --- app/models/webhook/delinquency_tracker.rb | 37 ++++++++++--------- ...s_count_to_webhook_delinquency_trackers.rb | 10 +++++ db/schema.rb | 5 +-- db/schema_cache.yml | 22 +++-------- .../fixtures/webhook/delinquency_trackers.yml | 10 ++--- .../webhook/delinquency_tracker_test.rb | 35 ++++++++---------- test/models/webhook/delivery_test.rb | 16 ++++---- 7 files changed, 65 insertions(+), 70 deletions(-) create mode 100644 db/migrate/20250915143513_add_consecutive_failures_count_to_webhook_delinquency_trackers.rb diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb index f3eafc06e..4fb2d174a 100644 --- a/app/models/webhook/delinquency_tracker.rb +++ b/app/models/webhook/delinquency_tracker.rb @@ -1,35 +1,38 @@ class Webhook::DelinquencyTracker < ApplicationRecord - LOW_VOLUME_TRESHOLD = 10 - RESET_INTERVAL = 1.hour + DELINQUENCY_THRESHOLD = 10 + CHECK_INTERVAL = 1.hour belongs_to :webhook - before_validation { self.last_reset_at ||= Time.current } - def record_delivery_of(delivery) - if delivery.failed? && high_volume? && reset_due? - webhook.deactivate if delinquent? + if delivery.succeeded? reset else - increment!(:total_count) - increment!(:failed_count) if delivery.failed? + mark_first_failure_time if consecutive_failures_count.zero? + increment!(:consecutive_failures_count) + + webhook.deactivate if delinquent? end end private - def high_volume? - total_count > LOW_VOLUME_TRESHOLD + def reset + update_columns consecutive_failures_count: 0, first_failure_at: nil + end + + def mark_first_failure_time + update_columns first_failure_at: Time.current end def delinquent? - failed_count == total_count + enough_time_passed? && (consecutive_failures_count >= DELINQUENCY_THRESHOLD) end - def reset_due? - last_reset_at.before?(RESET_INTERVAL.ago) - end - - def reset - update_columns total_count: 0, failed_count: 0, last_reset_at: Time.current + def enough_time_passed? + if first_failure_at + first_failure_at.before?(CHECK_INTERVAL.ago) + else + false + end end end diff --git a/db/migrate/20250915143513_add_consecutive_failures_count_to_webhook_delinquency_trackers.rb b/db/migrate/20250915143513_add_consecutive_failures_count_to_webhook_delinquency_trackers.rb new file mode 100644 index 000000000..13d1acf2d --- /dev/null +++ b/db/migrate/20250915143513_add_consecutive_failures_count_to_webhook_delinquency_trackers.rb @@ -0,0 +1,10 @@ +class AddConsecutiveFailuresCountToWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1] + def change + add_column :webhook_delinquency_trackers, :consecutive_failures_count, :integer + add_column :webhook_delinquency_trackers, :first_failure_at, :datetime + + remove_column :webhook_delinquency_trackers, :total_count, :integer + remove_column :webhook_delinquency_trackers, :failed_count, :integer + remove_column :webhook_delinquency_trackers, :last_reset_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 319f33939..ecf0c9097 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -456,10 +456,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do end create_table "webhook_delinquency_trackers", force: :cascade do |t| + t.integer "consecutive_failures_count" t.datetime "created_at", null: false - t.integer "failed_count", default: 0, null: false - t.datetime "last_reset_at" - t.integer "total_count", default: 0, null: false + t.datetime "first_failure_at" t.datetime "updated_at", null: false t.integer "webhook_id", null: false t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 3b8395663..0f88cc151 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1319,21 +1319,20 @@ columns: collation: comment: webhook_delinquency_trackers: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: failed_count + name: consecutive_failures_count cast_type: *3 sql_type_metadata: *4 - 'null': false - default: 0 + 'null': true + default: default_function: collation: comment: - - *6 + - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: last_reset_at + name: first_failure_at cast_type: *1 sql_type_metadata: *2 'null': true @@ -1341,16 +1340,7 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: total_count - cast_type: *3 - sql_type_metadata: *4 - 'null': false - default: 0 - default_function: - collation: - comment: + - *6 - *9 - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: diff --git a/test/fixtures/webhook/delinquency_trackers.yml b/test/fixtures/webhook/delinquency_trackers.yml index b4d4230b7..4a5086e69 100644 --- a/test/fixtures/webhook/delinquency_trackers.yml +++ b/test/fixtures/webhook/delinquency_trackers.yml @@ -2,12 +2,10 @@ active_webhook_tracker: webhook: active - last_reset_at: <%= 1.hour.ago %> - total_count: 1 - failed_count: 1 + consecutive_failures_count: 1 + first_failure_at: <%= 1.hour.ago %> inactive_webhook_tracker: webhook: inactive - last_reset_at: <%= 1.hour.ago %> - total_count: 1 - failed_count: 1 + consecutive_failures_count: 1 + first_failure_at: <%= 1.hour.ago %> diff --git a/test/models/webhook/delinquency_tracker_test.rb b/test/models/webhook/delinquency_tracker_test.rb index 80c55c2ba..e49d9332e 100644 --- a/test/models/webhook/delinquency_tracker_test.rb +++ b/test/models/webhook/delinquency_tracker_test.rb @@ -7,31 +7,28 @@ class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase successful_delivery = webhook_deliveries(:successfully_completed) failed_delivery = webhook_deliveries(:errored) - assert_difference -> { tracker.reload.total_count }, +1 do - assert_no_difference -> { tracker.reload.failed_count } do - tracker.record_delivery_of(successful_delivery) - end + tracker.update!(consecutive_failures_count: 5) + tracker.record_delivery_of(successful_delivery) + tracker.reload + + assert_equal 0, tracker.consecutive_failures_count + assert_nil tracker.first_failure_at + + assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do + tracker.record_delivery_of(failed_delivery) end - assert_difference -> { tracker.reload.total_count }, +1 do - assert_difference -> { tracker.reload.failed_count }, +1 do + tracker.reload + assert_not_nil tracker.first_failure_at + + assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do + assert_no_difference -> { tracker.reload.first_failure_at } do tracker.record_delivery_of(failed_delivery) end end - travel_to 13.hours.from_now do - tracker.update!(total_count: 11, failed_count: 5) - - tracker.record_delivery_of(failed_delivery) - tracker.reload - - assert_equal 0, tracker.total_count - assert_equal 0, tracker.failed_count - assert tracker.last_reset_at > 1.minute.ago - end - - travel_to 26.hours.from_now do - tracker.update!(total_count: 50, failed_count: 50) + travel_to 2.hours.from_now do + tracker.update!(consecutive_failures_count: 9) webhook.activate assert_changes -> { webhook.reload.active? }, from: true, to: false do diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index d79713717..53828c206 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -56,10 +56,10 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_equal "pending", delivery.state tracker = delivery.webhook.delinquency_tracker - assert_difference -> { tracker.reload.total_count }, 1 do - assert_no_difference -> { tracker.reload.failed_count } do - delivery.deliver - end + tracker.update!(consecutive_failures_count: 0) + + assert_no_difference -> { tracker.reload.consecutive_failures_count } do + delivery.deliver end assert delivery.persisted? @@ -75,15 +75,13 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase stub_request(:post, delivery.webhook.url).to_timeout tracker = delivery.webhook.delinquency_tracker - assert_difference -> { tracker.reload.total_count }, 1 do - assert_difference -> { tracker.reload.failed_count }, 1 do - delivery.deliver - end + assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do + delivery.deliver end assert_equal "completed", delivery.state assert_equal "connection_timeout", delivery.response[:error] - assert delivery.failed? + assert_not delivery.succeeded? end test "deliver when the connection is refused" do From 5b3089e11efff9387faa7cd4276088c2b661103c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Sep 2025 17:08:34 +0200 Subject: [PATCH 23/31] Change the icon if there are webhooks on a collection --- app/views/cards/_webhooks.html.erb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/views/cards/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb index bc18b0bc6..a2c36294c 100644 --- a/app/views/cards/_webhooks.html.erb +++ b/app/views/cards/_webhooks.html.erb @@ -1,4 +1,8 @@ <%= link_to collection_webhooks_path(collection_id: collection), class: "btn tooltip" do %> - <%= icon_tag "world" %> + <% if collection.webhooks.any? %> + <%= icon_tag "globe" %> + <% else %> + <%= icon_tag "world" %> + <% end %> Webhooks for <%= collection.name %> <% end %> From 63e64a060d0a4e4c5451de4943dd999f930d1ad6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Sep 2025 17:29:40 +0200 Subject: [PATCH 24/31] Use HTML5 validation for the URL --- app/controllers/webhooks_controller.rb | 16 ++++------------ app/views/webhooks/new.html.erb | 8 +++++++- test/controllers/webhooks_controller_test.rb | 2 -- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index ddd0f09da..fceb37c5c 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -17,24 +17,16 @@ class WebhooksController < ApplicationController end def create - @webhook = @collection.webhooks.new(webhook_params) - - if @webhook.save - redirect_to @webhook - else - render :new, status: :unprocessable_entity - end + @webhook = @collection.webhooks.create!(webhook_params) + redirect_to @webhook end def edit end def update - if @webhook.update(webhook_params.except(:url)) - redirect_to @webhook - else - render :edit, status: :unprocessable_entity - end + @webhook.update!(webhook_params.except(:url)) + redirect_to @webhook end def destroy diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 98c5642f7..780088117 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -11,7 +11,13 @@ <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> - <%= form.text_field :url, required: true, class: "input", placeholder: "https://example.com", data: { action: "keydown.esc@document->form#cancel" } %> + <%= form.url_field :url, + required: true, + pattern: "https?://.*", + title: "Must be an http:// or https:// URL", + class: "input", + placeholder: "https://example.com", + data: { action: "keydown.esc@document->form#cancel" } %>
      <%= form.label :actions %> diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index aa8696fa5..54d50a31b 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -56,7 +56,6 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest end assert_response :unprocessable_entity - assert_select "form" end test "edit" do @@ -91,7 +90,6 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest } assert_response :unprocessable_entity - assert_select "form" assert_no_changes -> { webhook.reload.url } do patch collection_webhook_path(webhook.collection, webhook), params: { From ce1d6469e72575be682a4ff2b2221cad641d22eb Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Sep 2025 20:14:31 +0200 Subject: [PATCH 25/31] Rename methods for clarity --- app/models/webhook/delinquency_tracker.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb index 4fb2d174a..6ae40eb4a 100644 --- a/app/models/webhook/delinquency_tracker.rb +++ b/app/models/webhook/delinquency_tracker.rb @@ -1,6 +1,6 @@ class Webhook::DelinquencyTracker < ApplicationRecord DELINQUENCY_THRESHOLD = 10 - CHECK_INTERVAL = 1.hour + DELINQUENCY_DURATION = 1.hour belongs_to :webhook @@ -25,14 +25,18 @@ class Webhook::DelinquencyTracker < ApplicationRecord end def delinquent? - enough_time_passed? && (consecutive_failures_count >= DELINQUENCY_THRESHOLD) + failing_for_too_long? && too_many_consecutive_failures? end - def enough_time_passed? + def failing_for_too_long? if first_failure_at - first_failure_at.before?(CHECK_INTERVAL.ago) + first_failure_at.before?(DELINQUENCY_DURATION.ago) else false end end + + def too_many_consecutive_failures? + consecutive_failures_count >= DELINQUENCY_THRESHOLD + end end From a5b4c88d2773aef7dde0f579b28cba8cda8525af Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 15 Sep 2025 18:01:34 -0500 Subject: [PATCH 26/31] Style web hooks forms and list WIP --- app/assets/stylesheets/utilities.css | 1 + app/views/webhooks/_webhook.html.erb | 6 +-- app/views/webhooks/edit.html.erb | 44 +++++++++++++---- app/views/webhooks/form/_actions.html.erb | 14 +++--- app/views/webhooks/index.html.erb | 59 +++++++++++++---------- app/views/webhooks/new.html.erb | 48 ++++++++++++------ 6 files changed, 113 insertions(+), 59 deletions(-) diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index dabbec2c9..3eb820bfb 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -193,6 +193,7 @@ /* Lists */ :where(.list-style-none) { + list-style: none; margin: 0 auto; padding: 0; } diff --git a/app/views/webhooks/_webhook.html.erb b/app/views/webhooks/_webhook.html.erb index 7f77b9a34..44b34c54d 100644 --- a/app/views/webhooks/_webhook.html.erb +++ b/app/views/webhooks/_webhook.html.erb @@ -1,11 +1,11 @@ -
    • - <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width" do %> +
    • + <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" do %> <%= webhook.name %> <% end %> - <%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative", + <%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative txt-xx-small flex-item-no-shrink", data: { turbo_confirm: "Are you sure you want to permanently remove this webhook?" } do %> <%= icon_tag "minus" %> Remove <%= webhook.name %> diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index c6358248d..8084c8b74 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -1,23 +1,49 @@ -<% @page_title = "Webhooks" %> +<% @page_title = @webhook.name %> <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> -

      <%= @page_title %>

      +
      + <%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + Webhooks + + <% end %> +
      + +

      <%= @page_title %>

      <% end %> -
      - <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +
      + <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>

      - <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> + <%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

      -
      - <%= form.label :actions %> +
      + <%= form.label :url do %> + Payload URL
      +

      This is the URL for the app that will receive payloads from Fizzy.

      + <% end %> + <%= form.url_field :url, + required: true, + pattern: "https?://.*", + title: "Must be an http:// or https:// URL", + class: "input", + placeholder: "https://example.com", + data: { action: "keydown.esc@document->form#cancel" } %> +
      + +
      + <%= form.label :actions do %> + Events
      +

      Trigger a call to the Payload URL when these things occur:

      + <% end %> <%= render "webhooks/form/actions", form: form %>
      - <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> - Update Webhook + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> + Save Changes <% end %> <%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %> diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb index bd592ca20..cd75e2dd6 100644 --- a/app/views/webhooks/form/_actions.html.erb +++ b/app/views/webhooks/form/_actions.html.erb @@ -1,16 +1,16 @@ -
        +
          <%= form.collection_check_boxes \ :subscribed_actions, Webhook::PERMITTED_ACTIONS, :itself, :humanize do |item| %> -
        • -
        • + + <%= item.text %>
        • <% end %>
        diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb index 3f2c47e19..43d1f696b 100644 --- a/app/views/webhooks/index.html.erb +++ b/app/views/webhooks/index.html.erb @@ -2,12 +2,16 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> - <%= link_to cards_path(collection_ids: [ @collection ]), class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> - - ← - <%= @collection.name %> - - <% end %> +
        + <%= link_to cards_path(collection_ids: [ @collection ]), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + <%= @collection.name %> + + <% end %> +
        + +

        <%= @page_title %>

        <%= link_to new_collection_webhook_path, class: "btn" do %> @@ -17,23 +21,28 @@
        <% end %> -
        - <%= tag.div class: "panel shadow center", data: { - controller: "navigable-list", - action: "keydown->navigable-list#navigate", - navigable_list_focus_on_selection_value: true, - navigable_list_actionable_items_value: true - } do %> -

        <%= @page_title %>

        - -
        -
          - <%= render partial: "webhooks/webhook", collection: @page.records %> -
        - -
        - Press to move, enter to visit webhook, SHIFT+ENTER to toggle. -
        -
        +<%= tag.section class: "panel shadow center webhooks", data: { + controller: "navigable-list", + action: "keydown->navigable-list#navigate", + navigable_list_focus_on_selection_value: true, + navigable_list_actionable_items_value: true +} do %> + <% if @page.records.any? %> +
          + <%= render partial: "webhooks/webhook", collection: @page.records %> +
        + <% else %> +

        Webhooks can notify another application when something happens in this Fizzy collection. You'll choose which events to subscribe to and provide a URL to receive the data.

        +

        For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.

        + <%= link_to new_collection_webhook_path, class: "btn btn--link" do %> + <%= icon_tag "add" %> + Set up a webhook + <% end %> <% end %> -
        + + <% if @page.records.many? %> +
        + Press to move, enter to visit webhook, SHIFT+ENTER to toggle. +
        + <% end %> +<% end %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 780088117..27d741fd2 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -1,30 +1,48 @@ -<% @page_title = "Webhooks" %> +<% @page_title = "Set up a Webhook" %> <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> -

        <%= @page_title %>

        +
        + <%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + Webhooks + + <% end %> +
        + +

        <%= @page_title %>

        <% end %> -
        - <%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %> +
        + <%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>

        - <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> + <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name this Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

        - <%= form.url_field :url, - required: true, - pattern: "https?://.*", - title: "Must be an http:// or https:// URL", - class: "input", - placeholder: "https://example.com", - data: { action: "keydown.esc@document->form#cancel" } %> +
        + <%= form.label :url do %> + Payload URL
        +

        This is the URL for the app that will receive payloads from Fizzy.

        + <% end %> + <%= form.url_field :url, + required: true, + pattern: "https?://.*", + title: "Must be an http:// or https:// URL", + class: "input", + placeholder: "https://example.com", + data: { action: "keydown.esc@document->form#cancel" } %> +
        -
        - <%= form.label :actions %> +
        + <%= form.label :actions do %> + Events
        +

        Trigger a call to the Payload URL when these things occur:

        + <% end %> <%= render "webhooks/form/actions", form: form %>
        - <%= form.button type: :submit, class: "btn btn--reversed center margin-block-start txt-medium" do %> + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> Create Webhook <% end %> From daee27194016504652367ea276081105d7b38f7f Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 16 Sep 2025 12:19:56 -0500 Subject: [PATCH 27/31] Style show view --- app/views/webhooks/_delivery.html.erb | 6 +- app/views/webhooks/edit.html.erb | 6 +- app/views/webhooks/show.html.erb | 79 +++++++++++++-------------- 3 files changed, 42 insertions(+), 49 deletions(-) diff --git a/app/views/webhooks/_delivery.html.erb b/app/views/webhooks/_delivery.html.erb index ced1f9e2c..8dba2f474 100644 --- a/app/views/webhooks/_delivery.html.erb +++ b/app/views/webhooks/_delivery.html.erb @@ -1,8 +1,8 @@ <%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %> -
        + <%= delivery.state %> -
        -
        + +
        <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
        <% end %> diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index 8084c8b74..a036d4b5b 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -3,15 +3,13 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %>
        - <%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + <%= link_to @webhook, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> ← - Webhooks + <%= @page_title %> <% end %>
        - -

        <%= @page_title %>

        <% end %>
        diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index a14cefe85..f4edfa392 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -2,54 +2,50 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> +
        + <%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + + ← + Webhooks + + <% end %> +
        - <%= link_to collection_webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> - - ← - Webhooks - - <% end %> -<% end %> - -
        -
        - <%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "user-edit-link btn" do %> +
        + <%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "btn" do %> <%= icon_tag "pencil" %> Edit <% end %> +
        +<% end %> -
        -

        <%= @webhook.name %>

        -
        - +
        +
        +

        <%= @webhook.name %>

        + <%= @webhook.url %> +
        + <% unless @webhook.active? %>
        - <%= @webhook.url %> + <%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
        + <% end %> - <% unless @webhook.active? %> -
        - <%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %> -
        - <% end %> +
        +

        Secret

        + <%= @webhook.signing_secret %> +

        + We'll send a X-Webhook-Signature header with each request. + You can generate a HMAC using SHA256 of the request body with this secret + to verify that the request came from us. +

        +
        -
        -

        Secret

        -

        - <%= @webhook.signing_secret %> -

        -

        - We'll sent a `X-Webhook-Signature` header with each request. - You can generate a HMAC using SHA256 of the request body with this secret - to verify that the request came from us. -

        -
        - -
        -

        Subscribed to

        +
        +

        Subscribed to

        <% if @webhook.subscribed_actions.empty? %> -

        This Webhook doesn't subscribe to any actions. It will never trigger.

        +

        This Webhook isn't subscribed to any events. It will never trigger.

        <% else %> -

          +
            <% @webhook.subscribed_actions.each do |action| %>
          • <%= action.humanize %>
          • <% end %> @@ -57,15 +53,14 @@ <% end %>
        -
        -

        Deliveries

        +
        +

        Deliveries

        <% if @webhook.deliveries.empty? %> -

        This Webhook hasn't been triggered yet

        +

        This Webhook hasn't been triggered yet

        <% else %> -

          +
            <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>
          <% end %>
        -
        From 784d6cf690e2eda7f17c308aca4134a14937c5e8 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 16 Sep 2025 12:28:14 -0500 Subject: [PATCH 28/31] Don't need to navigate this list I doubt most collections will ever have more than a handul --- app/views/webhooks/_webhook.html.erb | 2 +- app/views/webhooks/index.html.erb | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/app/views/webhooks/_webhook.html.erb b/app/views/webhooks/_webhook.html.erb index 44b34c54d..3a72a2473 100644 --- a/app/views/webhooks/_webhook.html.erb +++ b/app/views/webhooks/_webhook.html.erb @@ -1,4 +1,4 @@ -
      • +
      • <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" do %> <%= webhook.name %> <% end %> diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb index 43d1f696b..77a6c12b8 100644 --- a/app/views/webhooks/index.html.erb +++ b/app/views/webhooks/index.html.erb @@ -21,14 +21,9 @@
      • <% end %> -<%= tag.section class: "panel shadow center webhooks", data: { - controller: "navigable-list", - action: "keydown->navigable-list#navigate", - navigable_list_focus_on_selection_value: true, - navigable_list_actionable_items_value: true -} do %> +<%= tag.section class: "panel shadow center webhooks" do %> <% if @page.records.any? %> -
          +
            <%= render partial: "webhooks/webhook", collection: @page.records %>
          <% else %> @@ -39,10 +34,4 @@ Set up a webhook <% end %> <% end %> - - <% if @page.records.many? %> -
          - Press to move, enter to visit webhook, SHIFT+ENTER to toggle. -
          - <% end %> <% end %> From 2e9273c6ab7df8f063dc0d4310993831d335bb07 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 16 Sep 2025 12:32:22 -0500 Subject: [PATCH 29/31] Improve active state --- app/views/cards/_webhooks.html.erb | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/views/cards/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb index a2c36294c..2bdddaa3d 100644 --- a/app/views/cards/_webhooks.html.erb +++ b/app/views/cards/_webhooks.html.erb @@ -1,8 +1,4 @@ -<%= link_to collection_webhooks_path(collection_id: collection), class: "btn tooltip" do %> - <% if collection.webhooks.any? %> - <%= icon_tag "globe" %> - <% else %> - <%= icon_tag "world" %> - <% end %> - Webhooks for <%= collection.name %> +<%= link_to collection_webhooks_path(collection_id: collection), class: ["btn tooltip", {"btn--reversed": collection.webhooks.any?}] do %> + <%= icon_tag "world" %> + Webhooks <% end %> From b49e8d2d3456d0139db02a61b914aa3ce06621f3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Sep 2025 20:18:35 +0200 Subject: [PATCH 30/31] Remove URL field from edit form --- app/views/webhooks/edit.html.erb | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index a036d4b5b..d7cc99aff 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -18,20 +18,6 @@ <%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %> -
          - <%= form.label :url do %> - Payload URL
          -

          This is the URL for the app that will receive payloads from Fizzy.

          - <% end %> - <%= form.url_field :url, - required: true, - pattern: "https?://.*", - title: "Must be an http:// or https:// URL", - class: "input", - placeholder: "https://example.com", - data: { action: "keydown.esc@document->form#cancel" } %> -
          -
          <%= form.label :actions do %> Events
          From 34222b9717ee0d0bcd6c9d8e3f8191e3ef91efec Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Sep 2025 20:20:44 +0200 Subject: [PATCH 31/31] Add missing actions --- app/models/webhook.rb | 5 +++-- test/controllers/webhooks_controller_test.rb | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 9fbdada34..10d2e383d 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -10,8 +10,9 @@ class Webhook < ApplicationRecord card_assigned card_closed card_collection_changed - card_created - card_popped + card_due_date_added + card_due_date_changed + card_due_date_removed card_published card_reopened card_staged diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index 54d50a31b..80b4f5869 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -30,7 +30,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest webhook: { name: "Test Webhook", url: "https://example.com/webhook", - subscribed_actions: [ "", "card_created", "card_closed" ] + subscribed_actions: [ "", "card_published", "card_closed" ] } } end @@ -41,7 +41,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest assert_equal collection, webhook.collection assert_equal "Test Webhook", webhook.name assert_equal "https://example.com/webhook", webhook.url - assert_equal [ "card_created", "card_closed" ], webhook.subscribed_actions + assert_equal [ "card_published", "card_closed" ], webhook.subscribed_actions end test "create with invalid params" do @@ -70,7 +70,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest patch collection_webhook_path(webhook.collection, webhook), params: { webhook: { name: "Updated Webhook", - subscribed_actions: [ "card_created" ] + subscribed_actions: [ "card_published" ] } } @@ -78,7 +78,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest assert_redirected_to collection_webhook_path(webhook.collection, webhook) assert_equal "Updated Webhook", webhook.name - assert_equal [ "card_created" ], webhook.subscribed_actions + assert_equal [ "card_published" ], webhook.subscribed_actions end test "update with invalid params" do