diff --git a/Dockerfile b/Dockerfile
index dda1d3e09..63340efd0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -71,5 +71,5 @@ USER rails:rails
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start the server by default, this can be overwritten at runtime
-EXPOSE 80 443
+EXPOSE 80 443 9394
CMD ["./bin/thrust", "./bin/rails", "server"]
diff --git a/Gemfile b/Gemfile
index 6b9ef6bf2..c5ac64538 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,7 +1,7 @@
source "https://rubygems.org"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
-gem "rails", github: "flavorjones/rails", branch: "flavorjones/fix-script-name-in-root-mounted-engine"
+gem "rails", github: "rails/rails", branch: "main"
# Assets & front end
gem "importmap-rails"
@@ -42,6 +42,7 @@ gem "rails_structured_logging", bc: "rails-structured-logging"
gem "yabeda"
gem "yabeda-rails"
gem "yabeda-puma-plugin"
+gem "webrick" # required for yabeda-prometheus metrics server
gem "yabeda-prometheus-mmap"
gem "prometheus-client-mmap", "~> 1.1"
diff --git a/Gemfile.lock b/Gemfile.lock
index e15695962..4d38c63d0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -89,9 +89,17 @@ GIT
zeitwerk (~> 2)
GIT
- remote: https://github.com/flavorjones/rails.git
- revision: ba3d32ece6a1a1289b8981a987656dbf3d0e700c
- branch: flavorjones/fix-script-name-in-root-mounted-engine
+ remote: https://github.com/jeremy/mysql2.git
+ revision: b5766f5d296745d6aed014c8c0b7d82ef149ade1
+ ref: b5766f5d296745d6aed014c8c0b7d82ef149ade1
+ branch: force_latin1_to_utf8
+ specs:
+ mysql2 (0.5.4.latin1utf8)
+
+GIT
+ remote: https://github.com/rails/rails.git
+ revision: d8795bd762ab2716b6e7fd07a403566b28159cdc
+ branch: main
specs:
actioncable (8.1.0.beta1)
actionpack (= 8.1.0.beta1)
@@ -189,14 +197,6 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
-GIT
- remote: https://github.com/jeremy/mysql2.git
- revision: b5766f5d296745d6aed014c8c0b7d82ef149ade1
- ref: b5766f5d296745d6aed014c8c0b7d82ef149ade1
- branch: force_latin1_to_utf8
- specs:
- mysql2 (0.5.4.latin1utf8)
-
PATH
remote: gems/fizzy-saas
specs:
@@ -615,6 +615,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
+ webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
@@ -701,6 +702,7 @@ DEPENDENCIES
vcr
web-push
webmock
+ webrick
yabeda
yabeda-prometheus-mmap
yabeda-puma-plugin
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/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb
index 8bad68103..585e87400 100644
--- a/app/controllers/account/settings_controller.rb
+++ b/app/controllers/account/settings_controller.rb
@@ -3,6 +3,6 @@ class Account::SettingsController < ApplicationController
def show
@account = Account.sole
- @users = User.active
+ @users = User.active.alphabetically
end
end
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
new file mode 100644
index 000000000..ea30e1e87
--- /dev/null
+++ b/app/controllers/webhooks/activations_controller.rb
@@ -0,0 +1,14 @@
+class Webhooks::ActivationsController < ApplicationController
+ before_action :ensure_can_administer
+ 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
new file mode 100644
index 000000000..fceb37c5c
--- /dev/null
+++ b/app/controllers/webhooks_controller.rb
@@ -0,0 +1,51 @@
+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 @collection.webhooks.ordered
+ end
+
+ def show
+ end
+
+ def new
+ @webhook = @collection.webhooks.new
+ end
+
+ def create
+ @webhook = @collection.webhooks.create!(webhook_params)
+ redirect_to @webhook
+ end
+
+ def edit
+ end
+
+ def update
+ @webhook.update!(webhook_params.except(:url))
+ redirect_to @webhook
+ end
+
+ def destroy
+ @webhook.destroy!
+ redirect_to collection_webhooks_path, status: :see_other
+ end
+
+ private
+ def set_collection
+ @collection = Collection.find(params[:collection_id])
+ end
+
+ def set_webhook
+ @webhook = @collection.webhooks.find(params[:id])
+ end
+
+ def webhook_params
+ params
+ .expect(webhook: [ :name, :url, subscribed_actions: [] ])
+ .merge(collection_id: @collection.id)
+ end
+end
diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb
index c9cb66699..bc4dbbc28 100644
--- a/app/helpers/entropy_helper.rb
+++ b/app/helpers/entropy_helper.rb
@@ -22,7 +22,7 @@ module EntropyHelper
end
def card_entropy_action(card)
- if card.doing?
+ if card.stagnated?
"Falls Back"
elsif card.considering?
"Closes"
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 3fd040da0..0eecdb25f 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -30,9 +30,9 @@ module EventsHelper
def event_column_title(base_title, count, day)
date_tag = local_datetime_tag day, style: :agoorweekday
if count > 0
- "#{base_title} #{date_tag} (#{count})".html_safe
+ "#{h base_title} #{date_tag} (#{h count})".html_safe
else
- "#{base_title} #{date_tag}".html_safe
+ "#{h base_title} #{date_tag}".html_safe
end
end
@@ -72,7 +72,7 @@ module EventsHelper
end
def comment_event_action_sentence(event)
- "#{ event_creator_name(event) } commented on #{ event.eventable.card.title }".html_safe
+ "#{h event_creator_name(event) } commented on #{h event.eventable.card.title }".html_safe
end
def event_creator_name(event)
@@ -86,32 +86,32 @@ module EventsHelper
case event.action
when "card_assigned"
if event.assignees.include?(Current.user)
- "#{ event_creator_name(event) } will handle #{ title }".html_safe
+ "#{h event_creator_name(event) } will handle #{h title }".html_safe
else
- "#{ event_creator_name(event) } assigned #{ event.assignees.pluck(:name).to_sentence } to #{ title }".html_safe
+ "#{h event_creator_name(event) } assigned #{h event.assignees.pluck(:name).to_sentence } to #{h title }".html_safe
end
when "card_unassigned"
- "#{ event_creator_name(event) } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from #{ title }".html_safe
+ "#{h event_creator_name(event) } unassigned #{ h(event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence) } from #{h title }".html_safe
when "card_published"
- "#{ event_creator_name(event) } added #{ title }".html_safe
+ "#{h event_creator_name(event) } added #{h title }".html_safe
when "card_closed"
- "#{ event_creator_name(event) } closed #{ title }".html_safe
+ "#{h event_creator_name(event) } closed #{h title }".html_safe
when "card_reopened"
- "#{ event_creator_name(event) } reopened #{ title }".html_safe
+ "#{h event_creator_name(event) } reopened #{h title }".html_safe
when "card_staged"
- "#{event_creator_name(event)} moved #{ title } to the #{event.stage_name} stage".html_safe
+ "#{h event_creator_name(event)} moved #{h title } to the #{h event.stage_name} stage".html_safe
when "card_unstaged"
- "#{event_creator_name(event)} moved #{ title } out ofthe #{event.stage_name} stage".html_safe
+ "#{h event_creator_name(event)} moved #{h title } out ofthe #{h event.stage_name} stage".html_safe
when "card_due_date_added"
- "#{event_creator_name(event)} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on #{ title }".html_safe
+ "#{h event_creator_name(event)} set the date to #{h event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on #{h title }".html_safe
when "card_due_date_changed"
- "#{event_creator_name(event)} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on #{ title }".html_safe
+ "#{h event_creator_name(event)} changed the date to #{h event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on #{h title }".html_safe
when "card_due_date_removed"
- "#{event_creator_name(event)} removed the date on #{ title }"
+ "#{h event_creator_name(event)} removed the date on #{h title }".html_safe
when "card_title_changed"
- "#{event_creator_name(event)} renamed #{ title } (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
+ "#{h event_creator_name(event)} renamed #{h title } (was: '#{h event.particulars.dig('particulars', 'old_title')})'".html_safe
when "card_collection_changed"
- "#{event_creator_name(event)} moved #{ title } to '#{event.particulars.dig('particulars', 'new_collection')}'".html_safe
+ "#{h event_creator_name(event)} moved #{h title } to '#{h event.particulars.dig('particulars', 'new_collection')}'".html_safe
end
end
diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb
new file mode 100644
index 000000000..1df418cc8
--- /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.active.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/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/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb
new file mode 100644
index 000000000..95102b9d6
--- /dev/null
+++ b/app/jobs/webhook/delivery_job.rb
@@ -0,0 +1,7 @@
+class Webhook::DeliveryJob < ApplicationJob
+ queue_as :webhooks
+
+ def perform(delivery)
+ delivery.deliver
+ end
+end
diff --git a/app/models/access.rb b/app/models/access.rb
index 8c1b67a51..97748a4ee 100644
--- a/app/models/access.rb
+++ b/app/models/access.rb
@@ -2,7 +2,7 @@ class Access < ApplicationRecord
belongs_to :collection
belongs_to :user, touch: true
- enum :involvement, %i[ access_only watching ].index_by(&:itself)
+ enum :involvement, %i[ access_only watching ].index_by(&:itself), default: :access_only
scope :ordered_by_recently_accessed, -> { order(accessed_at: :desc) }
diff --git a/app/models/card/engageable.rb b/app/models/card/engageable.rb
index 2eeba4a46..0a006fd79 100644
--- a/app/models/card/engageable.rb
+++ b/app/models/card/engageable.rb
@@ -41,23 +41,13 @@ module Card::Engageable
def engage
unless doing?
- transaction do
- reopen
- create_engagement!(status: "doing")
- end
+ reengage(status: "doing")
end
end
def move_to_on_deck
unless on_deck?
- transaction do
- reopen
- if engagement.present?
- engagement.update!(status: "on_deck")
- else
- create_engagement!(status: "on_deck")
- end
- end
+ reengage(status: "on_deck")
end
end
@@ -69,4 +59,13 @@ module Card::Engageable
touch_last_active_at
end
end
+
+ private
+ def reengage(status:)
+ transaction do
+ reopen
+ engagement&.destroy
+ create_engagement!(status:)
+ end
+ end
end
diff --git a/app/models/card/entropic.rb b/app/models/card/entropic.rb
index cd65b34cc..a9dd1a5e9 100644
--- a/app/models/card/entropic.rb
+++ b/app/models/card/entropic.rb
@@ -8,7 +8,7 @@ module Card::Entropic
Entropy::Configuration.default.public_send(period_name))
end
- scope :stagnated, -> { doing.entropic_by(:auto_reconsider_period) }
+ scope :stagnated, -> { doing.or(on_deck).entropic_by(:auto_reconsider_period) }
scope :due_to_be_closed, -> { considering.entropic_by(:auto_close_period) }
@@ -20,7 +20,7 @@ module Card::Entropic
end
scope :falling_back_soon, -> do
- doing
+ doing.or(on_deck)
.left_outer_joins(collection: :entropy_configuration)
.where("last_active_at > DATETIME('now', '-' || COALESCE(entropy_configurations.auto_reconsider_period, ?) || ' seconds')", Entropy::Configuration.default.auto_reconsider_period)
.where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropy_configurations.auto_reconsider_period, ?) * 0.75 AS INTEGER) || ' seconds')", Entropy::Configuration.default.auto_reconsider_period)
@@ -48,4 +48,8 @@ module Card::Entropic
def entropic?
entropy.present?
end
+
+ def stagnated?
+ card.doing? || card.on_deck?
+ end
end
diff --git a/app/models/card/entropy.rb b/app/models/card/entropy.rb
index 51b1e54a4..a7ab7597d 100644
--- a/app/models/card/entropy.rb
+++ b/app/models/card/entropy.rb
@@ -7,7 +7,7 @@ class Card::Entropy
if card.considering?
new(card, card.auto_close_period)
- elsif card.doing?
+ elsif card.stagnated?
new(card, card.auto_reconsider_period)
end
end
diff --git a/app/models/card/watchable.rb b/app/models/card/watchable.rb
index 883975398..4d198ae48 100644
--- a/app/models/card/watchable.rb
+++ b/app/models/card/watchable.rb
@@ -21,10 +21,13 @@ module Card::Watchable
end
def watchers_and_subscribers(include_only_watching: false)
- involvements = include_only_watching ? [ :watching ] : []
- subscribers = collection.users.where(accesses: { involvement: involvements })
-
- User.where(id: subscribers.pluck(:id) +
+ User.active.where(id: subscribers(include_only_watching:).pluck(:id) +
watches.watching.pluck(:user_id) - watches.not_watching.pluck(:user_id))
end
+
+ private
+ def subscribers(include_only_watching: false)
+ involvements = include_only_watching ? [ :watching ] : []
+ collection.users.where(accesses: { involvement: involvements })
+ 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/collection/accessible.rb b/app/models/collection/accessible.rb
index 264d03af8..022185069 100644
--- a/app/models/collection/accessible.rb
+++ b/app/models/collection/accessible.rb
@@ -24,7 +24,7 @@ module Collection::Accessible
scope :all_access, -> { where(all_access: true) }
- after_create -> { accesses.grant_to creator }
+ after_create -> { grant_access_to_creator }
after_save_commit :grant_access_to_everyone
end
@@ -48,6 +48,10 @@ module Collection::Accessible
end
private
+ def grant_access_to_creator
+ accesses.create(user: creator, involvement: :watching)
+ end
+
def grant_access_to_everyone
accesses.grant_to(User.all) if all_access_previously_changed?(to: true)
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 811e207d3..c45702833 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -5,9 +5,12 @@ class Event < ApplicationRecord
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/notification/bundle.rb b/app/models/notification/bundle.rb
index c2da4224a..7c7ee6862 100644
--- a/app/models/notification/bundle.rb
+++ b/app/models/notification/bundle.rb
@@ -47,10 +47,15 @@ class Notification::Bundle < ApplicationRecord
DeliverJob.perform_later(self)
end
+ def flush
+ update!(ends_at: Time.current)
+ deliver_later
+ end
+
private
def set_default_window
self.starts_at ||= Time.current
- self.ends_at ||= user.settings.bundle_aggregation_period.from_now
+ self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period
end
def window
diff --git a/app/models/search/result.rb b/app/models/search/result.rb
index 16d7e2bdd..28e1f7172 100644
--- a/app/models/search/result.rb
+++ b/app/models/search/result.rb
@@ -3,6 +3,18 @@ class Search::Result < ApplicationRecord
belongs_to :card, foreign_key: :card_id, optional: true
belongs_to :comment, foreign_key: :comment_id, optional: true
+ def card_title
+ escape_highlight card_title_in_database
+ end
+
+ def card_description
+ escape_highlight card_description_in_database
+ end
+
+ def comment_body
+ escape_highlight comment_body_in_database
+ end
+
def source
comment_id.present? ? comment : card
end
@@ -10,4 +22,16 @@ class Search::Result < ApplicationRecord
def readonly?
true
end
+
+ private
+ def escape_highlight(html)
+ if html
+ CGI.escapeHTML(html)
+ .gsub(CGI.escapeHTML(Search::HIGHLIGHT_OPENING_MARK), Search::HIGHLIGHT_OPENING_MARK.html_safe)
+ .gsub(CGI.escapeHTML(Search::HIGHLIGHT_CLOSING_MARK), Search::HIGHLIGHT_CLOSING_MARK.html_safe)
+ .html_safe
+ else
+ nil
+ end
+ end
end
diff --git a/app/models/user/accessor.rb b/app/models/user/accessor.rb
index f2c366d04..c538c87a4 100644
--- a/app/models/user/accessor.rb
+++ b/app/models/user/accessor.rb
@@ -7,7 +7,7 @@ module User::Accessor
has_many :accessible_cards, through: :collections, source: :cards
has_many :accessible_comments, through: :accessible_cards, source: :comments
- after_create_commit :grant_access_to_collections
+ after_create_commit :grant_access_to_collections, unless: :system?
end
private
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/models/user/settings.rb b/app/models/user/settings.rb
index 7a89d2f5a..629b56326 100644
--- a/app/models/user/settings.rb
+++ b/app/models/user/settings.rb
@@ -47,9 +47,7 @@ class User::Settings < ApplicationRecord
end
def flush_pending_bundles
- user.notification_bundles.pending.find_each do |bundle|
- bundle.deliver_later
- end
+ user.notification_bundles.pending.find_each(&:flush)
end
def default_timezone
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
new file mode 100644
index 000000000..10d2e383d
--- /dev/null
+++ b/app/models/webhook.rb
@@ -0,0 +1,78 @@
+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_closed
+ card_collection_changed
+ card_due_date_added
+ card_due_date_changed
+ card_due_date_removed
+ card_published
+ card_reopened
+ card_staged
+ card_title_changed
+ card_unassigned
+ card_unstaged
+ comment_created
+ ].freeze
+
+ has_secure_token :signing_secret
+
+ 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) }
+ scope :active, -> { where(active: true) }
+
+ 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
+
+ def activate
+ update_columns active: true
+ end
+
+ def deactivate
+ update_columns active: false
+ end
+
+ def renderer
+ @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)
+
+ 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/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb
new file mode 100644
index 000000000..1824db418
--- /dev/null
+++ b/app/models/webhook/delinquency_tracker.rb
@@ -0,0 +1,42 @@
+class Webhook::DelinquencyTracker < ApplicationRecord
+ DELINQUENCY_THRESHOLD = 10
+ DELINQUENCY_DURATION = 1.hour
+
+ belongs_to :webhook
+
+ def record_delivery_of(delivery)
+ if delivery.succeeded?
+ reset
+ else
+ mark_first_failure_time if consecutive_failures_count.zero?
+ increment!(:consecutive_failures_count, touch: true)
+
+ webhook.deactivate if delinquent?
+ end
+ end
+
+ private
+ 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?
+ failing_for_too_long? && too_many_consecutive_failures?
+ end
+
+ def failing_for_too_long?
+ if first_failure_at
+ first_failure_at.before?(DELINQUENCY_DURATION.ago)
+ else
+ false
+ end
+ end
+
+ def too_many_consecutive_failures?
+ consecutive_failures_count >= DELINQUENCY_THRESHOLD
+ end
+end
diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb
new file mode 100644
index 000000000..fbd3398b0
--- /dev/null
+++ b/app/models/webhook/delivery.rb
@@ -0,0 +1,158 @@
+class Webhook::Delivery < ApplicationRecord
+ STALE_TRESHOLD = 7.days
+ USER_AGENT = "fizzy/1.0.0 Webhook"
+ ENDPOINT_TIMEOUT = 7.seconds
+ DNS_RESOLUTION_TIMEOUT = 2
+ DISALLOWED_IP_RANGES = [
+ # IPv4 mapped to IPv6
+ IPAddr.new("::ffff:0:0/96"),
+ # Broadcasts
+ IPAddr.new("0.0.0.0/8")
+ ].freeze
+
+ belongs_to :webhook
+ belongs_to :event
+
+ store :request, coder: JSON
+ store :response, coder: JSON
+
+ enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending
+
+ scope :ordered, -> { order created_at: :desc, id: :desc }
+ scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }
+
+ after_create_commit :deliver_later
+
+ def self.cleanup
+ stale.delete_all
+ end
+
+ def deliver_later
+ Webhook::DeliveryJob.perform_later(self)
+ end
+
+ def deliver
+ in_progress!
+
+ self.request[:headers] = headers
+ self.response = perform_request
+ self.state = :completed
+ save!
+
+ webhook.delinquency_tracker.record_delivery_of(self)
+ rescue
+ errored!
+ raise
+ end
+
+ def failed?
+ (errored? || completed?) && !succeeded?
+ end
+
+ def succeeded?
+ completed? && response[:error].blank? && response[:code].between?(200, 299)
+ end
+
+ private
+ def perform_request
+ if private_uri?
+ { error: :private_uri }
+ else
+ response = http.request(
+ Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
+ )
+
+ { code: response.code.to_i }
+ end
+ rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
+ { error: :dns_lookup_failed }
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
+ { error: :connection_timeout }
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
+ { error: :destination_unreachable }
+ rescue OpenSSL::SSL::SSLError
+ { error: :failed_tls }
+ end
+
+ def private_uri?
+ ip_addresses = []
+
+ Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
+ dns.each_address(uri.host) do |ip_address|
+ ip_addresses << IPAddr.new(ip_address)
+ end
+ end
+
+ ip_addresses.any? do |ip|
+ ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
+ end
+ end
+
+ def uri
+ @uri ||= URI(webhook.url)
+ end
+
+ def http
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
+ http.use_ssl = (uri.scheme == "https")
+ http.open_timeout = ENDPOINT_TIMEOUT
+ http.read_timeout = ENDPOINT_TIMEOUT
+ end
+ end
+
+ def headers
+ {
+ "User-Agent" => USER_AGENT,
+ "Content-Type" => content_type,
+ "X-Webhook-Signature" => signature,
+ "X-Webhook-Timestamp" => event.created_at.utc.iso8601
+ }
+ end
+
+ def signature
+ OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
+ end
+
+ def content_type
+ if webhook.for_campfire?
+ "text/html"
+ else
+ "application/json"
+ end
+ end
+
+ def payload
+ @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/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb
new file mode 100644
index 000000000..a2f5ea186
--- /dev/null
+++ b/app/models/webhook/triggerable.rb
@@ -0,0 +1,12 @@
+module Webhook::Triggerable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :triggered_by, ->(event) { where(collection: event.collection).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
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/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb
new file mode 100644
index 000000000..2bdddaa3d
--- /dev/null
+++ b/app/views/cards/_webhooks.html.erb
@@ -0,0 +1,4 @@
+<%= link_to collection_webhooks_path(collection_id: collection), class: ["btn tooltip", {"btn--reversed": collection.webhooks.any?}] do %>
+ <%= icon_tag "world" %>
+ Webhooks
+<% 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/cards/display/common/_meta.html.erb b/app/views/cards/display/common/_meta.html.erb
index 0ee04a99d..77d306198 100644
--- a/app/views/cards/display/common/_meta.html.erb
+++ b/app/views/cards/display/common/_meta.html.erb
@@ -27,7 +27,7 @@
<%= icon_tag "arrow-right" if card.assignees.any? %>
<%= card.assignees.any? ? "Assigned to" : "Not assigned" %>
- <%= card.assignees.map { |assignee| "#{assignee.familiar_name}" }.to_sentence.html_safe %>
+ <%= card.assignees.map { |assignee| "#{h assignee.familiar_name}" }.to_sentence.html_safe %>