diff --git a/app/controllers/admin/webhooks/secrets_controller.rb b/app/controllers/admin/webhooks/secrets_controller.rb
new file mode 100644
index 000000000..16af1cf7b
--- /dev/null
+++ b/app/controllers/admin/webhooks/secrets_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Admin
+ class Webhooks::SecretsController < BaseController
+ before_action :set_webhook
+
+ def rotate
+ authorize @webhook, :rotate_secret?
+ @webhook.rotate_secret!
+ redirect_to admin_webhook_path(@webhook)
+ end
+
+ private
+
+ def set_webhook
+ @webhook = Webhook.find(params[:webhook_id])
+ end
+ end
+end
diff --git a/app/controllers/admin/webhooks_controller.rb b/app/controllers/admin/webhooks_controller.rb
new file mode 100644
index 000000000..d6fb1a4ea
--- /dev/null
+++ b/app/controllers/admin/webhooks_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Admin
+ class WebhooksController < BaseController
+ before_action :set_webhook, except: [:index, :new, :create]
+
+ def index
+ authorize :webhook, :index?
+
+ @webhooks = Webhook.page(params[:page])
+ end
+
+ def new
+ authorize :webhook, :create?
+
+ @webhook = Webhook.new
+ end
+
+ def create
+ authorize :webhook, :create?
+
+ @webhook = Webhook.new(resource_params)
+
+ if @webhook.save
+ redirect_to admin_webhook_path(@webhook)
+ else
+ render :new
+ end
+ end
+
+ def show
+ authorize @webhook, :show?
+ end
+
+ def edit
+ authorize @webhook, :update?
+ end
+
+ def update
+ authorize @webhook, :update?
+
+ if @webhook.update(resource_params)
+ redirect_to admin_webhook_path(@webhook)
+ else
+ render :show
+ end
+ end
+
+ def enable
+ authorize @webhook, :enable?
+ @webhook.enable!
+ redirect_to admin_webhook_path(@webhook)
+ end
+
+ def disable
+ authorize @webhook, :disable?
+ @webhook.disable!
+ redirect_to admin_webhook_path(@webhook)
+ end
+
+ def destroy
+ authorize @webhook, :destroy?
+ @webhook.destroy!
+ redirect_to admin_webhooks_path
+ end
+
+ private
+
+ def set_webhook
+ @webhook = Webhook.find(params[:id])
+ end
+
+ def resource_params
+ params.require(:webhook).permit(:url, events: [])
+ end
+ end
+end
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 921c529d1..18638e18f 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -203,6 +203,14 @@ $content-width: 840px;
}
}
+ h2 small {
+ font-size: 12px;
+ display: block;
+ font-weight: 500;
+ color: $darker-text-color;
+ line-height: 18px;
+ }
+
@media screen and (max-width: $no-columns-breakpoint) {
border-bottom: 0;
padding-bottom: 0;
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index 852bff713..401bfd9ac 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
# == Schema Information
#
# Table name: admin_action_logs
diff --git a/app/models/report.rb b/app/models/report.rb
index 6d4166540..2efb6d4a7 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -55,6 +55,8 @@ class Report < ApplicationRecord
before_validation :set_uri, only: :create
+ after_create_commit :trigger_webhooks
+
def object_type
:flag
end
@@ -143,4 +145,8 @@ class Report < ApplicationRecord
errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size
end
+
+ def trigger_webhooks
+ TriggerWebhookWorker.perform_async('report.created', 'Report', id)
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 23febb6fe..81f6a58f6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -37,7 +37,6 @@
# sign_in_token_sent_at :datetime
# webauthn_id :string
# sign_up_ip :inet
-# skip_sign_in_token :boolean
#
class User < ApplicationRecord
@@ -120,6 +119,7 @@ class User < ApplicationRecord
before_validation :sanitize_languages
before_create :set_approved
after_commit :send_pending_devise_notifications
+ after_create_commit :trigger_webhooks
# This avoids a deprecation warning from Rails 5.1
# It seems possible that a future release of devise-two-factor will
@@ -182,7 +182,9 @@ class User < ApplicationRecord
end
def update_sign_in!(new_sign_in: false)
- old_current, new_current = current_sign_in_at, Time.now.utc
+ old_current = current_sign_in_at
+ new_current = Time.now.utc
+
self.last_sign_in_at = old_current || new_current
self.current_sign_in_at = new_current
@@ -472,4 +474,8 @@ class User < ApplicationRecord
def invite_text_required?
Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check?
end
+
+ def trigger_webhooks
+ TriggerWebhookWorker.perform_async('account.created', 'Account', account_id)
+ end
end
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
new file mode 100644
index 000000000..431edd75d
--- /dev/null
+++ b/app/models/webhook.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: webhooks
+#
+# id :bigint(8) not null, primary key
+# url :string not null
+# events :string default([]), not null, is an Array
+# secret :string default(""), not null
+# enabled :boolean default(TRUE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class Webhook < ApplicationRecord
+ EVENTS = %w(
+ account.created
+ report.created
+ ).freeze
+
+ scope :enabled, -> { where(enabled: true) }
+
+ validates :url, presence: true, url: true
+ validates :secret, presence: true, length: { minimum: 12 }
+ validates :events, presence: true
+
+ validate :validate_events
+
+ before_validation :strip_events
+ before_validation :generate_secret
+
+ def rotate_secret!
+ update!(secret: SecureRandom.hex(20))
+ end
+
+ def enable!
+ update!(enabled: true)
+ end
+
+ def disable!
+ update!(enabled: false)
+ end
+
+ private
+
+ def validate_events
+ errors.add(:events, :invalid) if events.any? { |e| !EVENTS.include?(e) }
+ end
+
+ def strip_events
+ self.events = events.map { |str| str.strip.presence }.compact if events.present?
+ end
+
+ def generate_secret
+ self.secret = SecureRandom.hex(20) if secret.blank?
+ end
+end
diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb
new file mode 100644
index 000000000..2c55703a1
--- /dev/null
+++ b/app/policies/webhook_policy.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class WebhookPolicy < ApplicationPolicy
+ def index?
+ admin?
+ end
+
+ def create?
+ admin?
+ end
+
+ def show?
+ admin?
+ end
+
+ def update?
+ admin?
+ end
+
+ def enable?
+ admin?
+ end
+
+ def disable?
+ admin?
+ end
+
+ def rotate_secret?
+ admin?
+ end
+
+ def destroy?
+ admin?
+ end
+end
diff --git a/app/presenters/webhooks/event_presenter.rb b/app/presenters/webhooks/event_presenter.rb
new file mode 100644
index 000000000..dac14a3f0
--- /dev/null
+++ b/app/presenters/webhooks/event_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Webhooks::EventPresenter < ActiveModelSerializers::Model
+ attributes :type, :created_at, :object
+
+ def initialize(type, object)
+ super()
+
+ @type = type
+ @created_at = Time.now.utc
+ @object = object
+ end
+end
diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb
index 74bc0c520..237f41d8e 100644
--- a/app/serializers/rest/admin/report_serializer.rb
+++ b/app/serializers/rest/admin/report_serializer.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
class REST::Admin::ReportSerializer < ActiveModel::Serializer
- attributes :id, :action_taken, :category, :comment, :created_at, :updated_at
+ attributes :id, :action_taken, :action_taken_at, :category, :comment,
+ :created_at, :updated_at
has_one :account, serializer: REST::Admin::AccountSerializer
has_one :target_account, serializer: REST::Admin::AccountSerializer
diff --git a/app/serializers/rest/admin/webhook_event_serializer.rb b/app/serializers/rest/admin/webhook_event_serializer.rb
new file mode 100644
index 000000000..fe0ac23f9
--- /dev/null
+++ b/app/serializers/rest/admin/webhook_event_serializer.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class REST::Admin::WebhookEventSerializer < ActiveModel::Serializer
+ def self.serializer_for(model, options)
+ case model.class.name
+ when 'Account'
+ REST::Admin::AccountSerializer
+ when 'Report'
+ REST::Admin::ReportSerializer
+ else
+ super
+ end
+ end
+
+ attributes :event, :created_at
+
+ has_one :virtual_object, key: :object
+
+ def virtual_object
+ object.object
+ end
+
+ def event
+ object.type
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index 99e8c875f..b0c0f9ec4 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -5,4 +5,8 @@ class BaseService
include ActionView::Helpers::SanitizeHelper
include RoutingHelper
+
+ def call(*)
+ raise NotImplementedError
+ end
end
diff --git a/app/services/webhook_service.rb b/app/services/webhook_service.rb
new file mode 100644
index 000000000..aafa38318
--- /dev/null
+++ b/app/services/webhook_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class WebhookService < BaseService
+ def call(event, object)
+ @event = Webhooks::EventPresenter.new(event, object)
+ @body = serialize_event
+
+ webhooks_for_event.each do |webhook_id|
+ Webhooks::DeliveryWorker.perform_async(webhook_id, @body)
+ end
+ end
+
+ private
+
+ def webhooks_for_event
+ Webhook.enabled.where('? = ANY(events)', @event.type).pluck(:id)
+ end
+
+ def serialize_event
+ Oj.dump(ActiveModelSerializers::SerializableResource.new(@event, serializer: REST::Admin::WebhookEventSerializer, scope: nil, scope_name: :current_user).as_json)
+ end
+end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index f50abbe24..75d1edb87 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -2,7 +2,7 @@
class URLValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value)
+ record.errors.add(attribute, :invalid) unless compliant?(value)
end
private
diff --git a/app/views/admin/webhooks/_form.html.haml b/app/views/admin/webhooks/_form.html.haml
new file mode 100644
index 000000000..c1e8f8979
--- /dev/null
+++ b/app/views/admin/webhooks/_form.html.haml
@@ -0,0 +1,11 @@
+= simple_form_for @webhook, url: @webhook.new_record? ? admin_webhooks_path : admin_webhook_path(@webhook) do |f|
+ = render 'shared/error_messages', object: @webhook
+
+ .fields-group
+ = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
+
+ .fields-group
+ = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+
+ .actions
+ = f.button :button, @webhook.new_record? ? t('admin.webhooks.add_new') : t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/webhooks/_webhook.html.haml b/app/views/admin/webhooks/_webhook.html.haml
new file mode 100644
index 000000000..d94a41eb3
--- /dev/null
+++ b/app/views/admin/webhooks/_webhook.html.haml
@@ -0,0 +1,19 @@
+.applications-list__item
+ = link_to admin_webhook_path(webhook), class: 'announcements-list__item__title' do
+ = fa_icon 'inbox'
+ = webhook.url
+
+ .announcements-list__item__action-bar
+ .announcements-list__item__meta
+ - if webhook.enabled?
+ %span.positive-hint= t('admin.webhooks.enabled')
+ - else
+ %span.negative-hint= t('admin.webhooks.disabled')
+
+ •
+
+ %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)
+
+ %div
+ = table_link_to 'pencil', t('admin.webhooks.edit'), edit_admin_webhook_path(webhook) if can?(:update, webhook)
+ = table_link_to 'trash', t('admin.webhooks.delete'), admin_webhook_path(webhook), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, webhook)
diff --git a/app/views/admin/webhooks/edit.html.haml b/app/views/admin/webhooks/edit.html.haml
new file mode 100644
index 000000000..3dc0ace9b
--- /dev/null
+++ b/app/views/admin/webhooks/edit.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_title do
+ = t('admin.webhooks.edit')
+
+= render partial: 'form'
diff --git a/app/views/admin/webhooks/index.html.haml b/app/views/admin/webhooks/index.html.haml
new file mode 100644
index 000000000..e4499e078
--- /dev/null
+++ b/app/views/admin/webhooks/index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+ = t('admin.webhooks.title')
+
+- content_for :heading_actions do
+ = link_to t('admin.webhooks.add_new'), new_admin_webhook_path, class: 'button' if can?(:create, :webhook)
+
+%p= t('admin.webhooks.description_html')
+
+%hr.spacer/
+
+- if @webhooks.empty?
+ %div.muted-hint.center-text
+ = t 'admin.webhooks.empty'
+- else
+ .applications-list
+ = render partial: 'webhook', collection: @webhooks
+
+ = paginate @webhooks
diff --git a/app/views/admin/webhooks/new.html.haml b/app/views/admin/webhooks/new.html.haml
new file mode 100644
index 000000000..1258df74a
--- /dev/null
+++ b/app/views/admin/webhooks/new.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_title do
+ = t('admin.webhooks.new')
+
+= render partial: 'form'
diff --git a/app/views/admin/webhooks/show.html.haml b/app/views/admin/webhooks/show.html.haml
new file mode 100644
index 000000000..cc450de26
--- /dev/null
+++ b/app/views/admin/webhooks/show.html.haml
@@ -0,0 +1,34 @@
+- content_for :page_title do
+ = t('admin.webhooks.title')
+
+- content_for :heading do
+ %h2
+ %small
+ = fa_icon 'inbox'
+ = t('admin.webhooks.webhook')
+ = @webhook.url
+
+- content_for :heading_actions do
+ = link_to t('admin.webhooks.edit'), edit_admin_webhook_path, class: 'button' if can?(:update, @webhook)
+
+.table-wrapper
+ %table.table.horizontal-table
+ %tbody
+ %tr
+ %th= t('admin.webhooks.status')
+ %td
+ - if @webhook.enabled?
+ %span.positive-hint= t('admin.webhooks.enabled')
+ = table_link_to 'power-off', t('admin.webhooks.disable'), disable_admin_webhook_path(@webhook), method: :post if can?(:disable, @webhook)
+ - else
+ %span.negative-hint= t('admin.webhooks.disabled')
+ = table_link_to 'power-off', t('admin.webhooks.enable'), enable_admin_webhook_path(@webhook), method: :post if can?(:enable, @webhook)
+ %tr
+ %th= t('admin.webhooks.events')
+ %td
+ %abbr{ title: @webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: @webhook.events.size)
+ %tr
+ %th= t('admin.webhooks.secret')
+ %td
+ %samp= @webhook.secret
+ = table_link_to 'refresh', t('admin.webhooks.rotate_secret'), rotate_admin_webhook_secret_path(@webhook), method: :post if can?(:rotate_secret, @webhook)
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 62716ab1e..0f6433781 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -23,7 +23,10 @@
.content-wrapper
.content
.content-heading
- %h2= yield :page_title
+ - if content_for?(:heading)
+ = yield :heading
+ - else
+ %h2= yield :page_title
- if :heading_actions
.content-heading-actions
diff --git a/app/workers/trigger_webhook_worker.rb b/app/workers/trigger_webhook_worker.rb
new file mode 100644
index 000000000..2ffb6246f
--- /dev/null
+++ b/app/workers/trigger_webhook_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class TriggerWebhookWorker
+ include Sidekiq::Worker
+
+ def perform(event, class_name, id)
+ object = class_name.constantize.find(id)
+ WebhookService.new.call(event, object)
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/app/workers/webhooks/delivery_worker.rb b/app/workers/webhooks/delivery_worker.rb
new file mode 100644
index 000000000..b1e345c5e
--- /dev/null
+++ b/app/workers/webhooks/delivery_worker.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class Webhooks::DeliveryWorker
+ include Sidekiq::Worker
+ include JsonLdHelper
+
+ sidekiq_options queue: 'push', retry: 16, dead: false
+
+ def perform(webhook_id, body)
+ @webhook = Webhook.find(webhook_id)
+ @body = body
+ @response = nil
+
+ perform_request
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+
+ private
+
+ def perform_request
+ request = Request.new(:post, @webhook.url, body: @body)
+
+ request.add_headers(
+ 'Content-Type' => 'application/json',
+ 'X-Hub-Signature' => "sha256=#{signature}"
+ )
+
+ request.perform do |response|
+ raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response)
+ end
+ end
+
+ def signature
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), @webhook.secret, @body)
+ end
+end
diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml
index d5f19ca64..720b0f5e3 100644
--- a/config/locales/activerecord.en.yml
+++ b/config/locales/activerecord.en.yml
@@ -21,6 +21,14 @@ en:
username:
invalid: must contain only letters, numbers and underscores
reserved: is reserved
+ admin/webhook:
+ attributes:
+ url:
+ invalid: is not a valid URL
+ doorkeeper/application:
+ attributes:
+ website:
+ invalid: is not a valid URL
status:
attributes:
reblog:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6bb0cc7ab..b73b352c7 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -852,6 +852,26 @@ en:
edit_preset: Edit warning preset
empty: You haven't defined any warning presets yet.
title: Manage warning presets
+ webhooks:
+ add_new: Add endpoint
+ delete: Delete
+ description_html: A webhook enables Mastodon to push real-time notifications about chosen events to your own application, so your application can automatically trigger reactions.
+ disable: Disable
+ disabled: Disabled
+ edit: Edit endpoint
+ empty: You don't have any webhook endpoints configured yet.
+ enable: Enable
+ enabled: Active
+ enabled_events:
+ one: 1 enabled event
+ other: "%{count} enabled events"
+ events: Events
+ new: New webhook
+ rotate_secret: Rotate secret
+ secret: Signing secret
+ status: Status
+ title: Webhooks
+ webhook: Webhook
admin_mailer:
new_appeal:
actions:
@@ -916,7 +936,6 @@ en:
applications:
created: Application successfully created
destroyed: Application successfully deleted
- invalid_url: The provided URL is invalid
regenerate_token: Regenerate access token
token_regenerated: Access token successfully regenerated
warning: Be very careful with this data. Never share it with anyone!
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index b784b1da7..7e4f52849 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -91,6 +91,9 @@ en:
name: You can only change the casing of the letters, for example, to make it more readable
user:
chosen_languages: When checked, only posts in selected languages will be displayed in public timelines
+ webhook:
+ events: Select events to send
+ url: Where events will be sent to
labels:
account:
fields:
@@ -219,6 +222,9 @@ en:
name: Hashtag
trendable: Allow this hashtag to appear under trends
usable: Allow posts to use this hashtag
+ webhook:
+ events: Enabled events
+ url: Endpoint URL
'no': 'No'
recommended: Recommended
required:
diff --git a/config/navigation.rb b/config/navigation.rb
index 620f78c57..ec5719e3e 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -56,6 +56,7 @@ SimpleNavigation::Configuration.run do |navigation|
s.item :rules, safe_join([fa_icon('gavel fw'), t('admin.rules.title')]), admin_rules_path, highlights_on: %r{/admin/rules}
s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
+ s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}
s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index dfce94929..87833539f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -235,6 +235,17 @@ Rails.application.routes.draw do
resources :rules
+ resources :webhooks do
+ member do
+ post :enable
+ post :disable
+ end
+
+ resource :secret, only: [], controller: 'webhooks/secrets' do
+ post :rotate
+ end
+ end
+
resources :reports, only: [:index, :show] do
resources :actions, only: [:create], controller: 'reports/actions'
diff --git a/db/migrate/20220606044941_create_webhooks.rb b/db/migrate/20220606044941_create_webhooks.rb
new file mode 100644
index 000000000..cca48fce6
--- /dev/null
+++ b/db/migrate/20220606044941_create_webhooks.rb
@@ -0,0 +1,12 @@
+class CreateWebhooks < ActiveRecord::Migration[6.1]
+ def change
+ create_table :webhooks do |t|
+ t.string :url, null: false, index: { unique: true }
+ t.string :events, array: true, null: false, default: []
+ t.string :secret, null: false, default: ''
+ t.boolean :enabled, null: false, default: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 081955660..5d8aea601 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2022_05_27_114923) do
+ActiveRecord::Schema.define(version: 2022_06_06_044941) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -1035,6 +1035,16 @@ ActiveRecord::Schema.define(version: 2022_05_27_114923) do
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
end
+ create_table "webhooks", force: :cascade do |t|
+ t.string "url", null: false
+ t.string "events", default: [], null: false, array: true
+ t.string "secret", default: "", null: false
+ t.boolean "enabled", default: true, null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["url"], name: "index_webhooks_on_url", unique: true
+ end
+
add_foreign_key "account_aliases", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "conversations", on_delete: :cascade
diff --git a/spec/fabricators/webhook_fabricator.rb b/spec/fabricators/webhook_fabricator.rb
new file mode 100644
index 000000000..fa4f17b55
--- /dev/null
+++ b/spec/fabricators/webhook_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:webhook) do
+ url { Faker::Internet.url }
+ secret { SecureRandom.hex }
+ events { Webhook::EVENTS }
+end
diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb
new file mode 100644
index 000000000..60c3d9524
--- /dev/null
+++ b/spec/models/webhook_spec.rb
@@ -0,0 +1,32 @@
+require 'rails_helper'
+
+RSpec.describe Webhook, type: :model do
+ let(:webhook) { Fabricate(:webhook) }
+
+ describe '#rotate_secret!' do
+ it 'changes the secret' do
+ previous_value = webhook.secret
+ webhook.rotate_secret!
+ expect(webhook.secret).to_not be_blank
+ expect(webhook.secret).to_not eq previous_value
+ end
+ end
+
+ describe '#enable!' do
+ before do
+ webhook.disable!
+ end
+
+ it 'enables the webhook' do
+ webhook.enable!
+ expect(webhook.enabled?).to be true
+ end
+ end
+
+ describe '#disable!' do
+ it 'disables the webhook' do
+ webhook.disable!
+ expect(webhook.enabled?).to be false
+ end
+ end
+end
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
index a44878a44..85eadeb63 100644
--- a/spec/validators/url_validator_spec.rb
+++ b/spec/validators/url_validator_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe URLValidator, type: :validator do
let(:compliant) { false }
it 'calls errors.add' do
- expect(errors).to have_received(:add).with(attribute, I18n.t('applications.invalid_url'))
+ expect(errors).to have_received(:add).with(attribute, :invalid)
end
end