Add administrative webhooks (#18510)
* Add administrative webhooks * Fix error when webhook is deleted before delivery worker runslocal
parent
17ba5e1e61
commit
a2871cd747
33 changed files with 530 additions and 8 deletions
@ -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