Add administrative webhooks (#18510)
* Add administrative webhooks * Fix error when webhook is deleted before delivery worker runsmain
parent
17ba5e1e61
commit
a2871cd747
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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)
|
@ -0,0 +1,4 @@
|
||||
- content_for :page_title do
|
||||
= t('admin.webhooks.edit')
|
||||
|
||||
= render partial: 'form'
|
@ -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
|
@ -0,0 +1,4 @@
|
||||
- content_for :page_title do
|
||||
= t('admin.webhooks.new')
|
||||
|
||||
= render partial: 'form'
|
@ -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)
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,5 @@
|
||||
Fabricator(:webhook) do
|
||||
url { Faker::Internet.url }
|
||||
secret { SecureRandom.hex }
|
||||
events { Webhook::EVENTS }
|
||||
end
|
@ -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
|
Loading…
Reference in new issue