diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 748289c..954b3b3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,62 +1,65 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception attr_writer :breadcrumb helper_method :admin? def admin? return false if params.key?('user') return false if current_user.nil? @admin_count ||= begin current_user .groups .where(name: WebDNS.settings[:admin_group]).count end @admin_count != 0 end def admin_only! return if admin? redirect_to root_path, alert: 'Admin only area!' end private def group @group ||= edit_group_scope.find(params[:group_id] || params[:id]) end def domain @domain ||= edit_domain_scope.find(params[:domain_id] || params[:id]) end def record @record ||= record_scope.find(params[:record_id] || params[:id]) end def show_group_scope @show_group_scope ||= current_user.groups end def edit_group_scope @edit_group_scope ||= admin? ? Group.all : show_group_scope end def show_domain_scope @show_domain_scope ||= Domain.where(group: show_group_scope) end def edit_domain_scope @edit_domain_scope ||= admin? ? Domain.all : Domain.where(group: show_group_scope) end def record_scope @record_scope ||= domain.records end + def notification + Notification.instance + end end diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb index 160b07b..72f9c0b 100644 --- a/app/controllers/domains_controller.rb +++ b/app/controllers/domains_controller.rb @@ -1,67 +1,74 @@ class DomainsController < ApplicationController before_action :authenticate_user! before_action :domain, only: [:show, :edit, :update, :destroy] before_action :group, only: [:show, :edit, :update, :destroy] helper_method :edit_group_scope # GET /domains def index @domains = show_domain_scope.all end # GET /domains/1 def show @record = Record.new(domain_id: @domain.id) end # GET /domains/new def new @domain = Domain.new end # GET /domains/1/edit def edit end # POST /domains def create @domain = Domain.new(domain_params) if @domain.save + notify_domain(@domain, :create) redirect_to @domain, notice: "#{@domain.name} was successfully created." else render :new end end # PATCH/PUT /domains/1 def update if @domain.update(domain_params) + notify_domain(@domain, :update) redirect_to @domain, notice: "#{@domain.name} was successfully updated." else render :edit end end # DELETE /domains/1 def destroy @domain.destroy + notify_domain(@domain, :destroy) redirect_to domains_url, notice: "#{@domain.name} was successfully destroyed." end private def group domain.group end def domain_params params.require(:domain).tap { |d| # Make sure group id is permitted (belongs to edit_group_scope) d[:group_id] = edit_group_scope.find_by_id(d[:group_id]).try(:id) }.permit(:name, :type, :master, :group_id) end + def notify_domain(*args) + notification.notify_domain(current_user, *args) + end + end diff --git a/app/controllers/records_controller.rb b/app/controllers/records_controller.rb index 2090230..5123ddc 100644 --- a/app/controllers/records_controller.rb +++ b/app/controllers/records_controller.rb @@ -1,62 +1,69 @@ class RecordsController < ApplicationController before_action :authenticate_user! before_action :domain before_action :record, only: [:edit, :update, :destroy] # GET /records/new def new @record = domain.records.build end # GET /records/1/edit def edit end # POST /records def create @record = domain.records.new(new_record_params) if @record.save + notify_record(@record, :create) redirect_to domain, notice: 'Record was successfully created.' else flash[:alert] = 'There were some errors creating the record!' render :new end end # PATCH/PUT /records/1 def update if @record.update(edit_record_params) + notify_record(@record, :update) redirect_to domain, notice: 'Record was successfully updated.' else render :edit end end # DELETE /records/1 def destroy @record.destroy + notify_record(@record, :destroy) redirect_to domain, notice: 'Record was successfully destroyed.' end private def edit_record_params if @record.type == 'SOA' permitted = [:contact, :serial, :refresh, :retry, :expire, :nx] else permitted = [:name, :content, :ttl, :prio, :disable] end params.require(:record).permit(*permitted).tap { |r| r[:drop_privileges] = true if not admin? } end def new_record_params params.require(:record).permit(:name, :content, :ttl, :type, :prio).tap { |r| r[:drop_privileges] = true if not admin? } end + + def notify_record(*args) + notification.notify_record(current_user, *args) + end end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb new file mode 100644 index 0000000..c7cde23 --- /dev/null +++ b/app/mailers/notification_mailer.rb @@ -0,0 +1,29 @@ +class NotificationMailer < ActionMailer::Base + default from: WebDNS.settings[:mail_from] + + PREFIXES = { + create: 'Created', + update: 'Modified', + destroy: 'Deleted', + } + + def notify_record(record:, context:, user:, admin:, others:, changes:) + @record = record + @context = context + @user = user + @admin = admin + @changes = changes + + mail(to: others, subject: "[webdns] [record] #{PREFIXES[context.to_sym]} #{record.to_short_dns}") + end + + def notify_domain(domain:, context:, user:, admin:, others:, changes:) + @domain = domain + @context = context + @user = user + @admin = admin + @changes = changes + + mail(to: others, subject: "[webdns] [domain] #{PREFIXES[context.to_sym]} #{domain.name}") + end +end diff --git a/app/views/notification_mailer/notify_domain.text.erb b/app/views/notification_mailer/notify_domain.text.erb new file mode 100644 index 0000000..ab2b51d --- /dev/null +++ b/app/views/notification_mailer/notify_domain.text.erb @@ -0,0 +1,16 @@ +Action: <%= @context %> +By: <%= @user.email %> <%= '(admin)' if @admin %> + +Domain: <%= @domain.name %> +Type: <%= @domain.type %> +<% if @domain.slave? %> +Master: <%= @domain.master %> +<% end -%> + +<% if @context == :update -%> +Changes: +<% @changes.each do |key, change| -%> +<%= key %> from <%= change.first || "(empty)" %> +<%= key %> to <%= change.last %> +<% end -%> +<% end -%> diff --git a/app/views/notification_mailer/notify_record.text.erb b/app/views/notification_mailer/notify_record.text.erb new file mode 100644 index 0000000..db5e335 --- /dev/null +++ b/app/views/notification_mailer/notify_record.text.erb @@ -0,0 +1,15 @@ +Action: <%= @context %> +By: <%= @user.email %> <%= '(admin)' if @admin %> + +Record: <%= @record.name %> +Type: <%= @record.type %> +Domain: <%= @record.domain.name %> +State: <%= @record.to_dns %> + +<% if @context == :update -%> +Changes: +<% @changes.each do |key, change| -%> +<%= key %> from <%= change.first %> +<%= key %> to <%= change.last %> +<% end -%> +<% end -%> diff --git a/config/initializers/notification.rb b/config/initializers/notification.rb new file mode 100644 index 0000000..ef750d3 --- /dev/null +++ b/config/initializers/notification.rb @@ -0,0 +1 @@ +Notification.instance.hook diff --git a/lib/notification.rb b/lib/notification.rb new file mode 100644 index 0000000..ec8063c --- /dev/null +++ b/lib/notification.rb @@ -0,0 +1,93 @@ +require 'singleton' + +class Notification + include Singleton + + # Send out a notification about notable record changes + def notify_record(user, record, context) + ActiveSupport::Notifications.instrument( + 'webdns.record', + user: user, + context: context, + object: record) + end + + # Send out a notification about notable domain changes + def notify_domain(user, domain, context) + ActiveSupport::Notifications.instrument( + 'webdns.domain', + user: user, + context: context, + object: domain) + end + + # Subscribe to domain/record notifications + def hook + hook_record + hook_domain + end + + private + + def hook_record + ActiveSupport::Notifications + .subscribe 'webdns.record' do |_name, _started, _finished, _unique_id, data| + handle_record(data) + end + end + + def hook_domain + ActiveSupport::Notifications + .subscribe 'webdns.domain' do |_name, _started, _finished, _unique_id, data| + handle_domain(data) + end + end + + def handle_record(data) # rubocop:disable Metrics/MethodLength + record, context, user = data.values_at(:object, :context, :user) + domain = record.domain + changes = record.previous_changes + + # Nobody is interested in those + changes.delete('updated_at') + changes.delete('created_at') + return if changes.empty? + + others = domain.group.users.where.not(id: user.id).pluck(:email) + return if others.empty? + + admin_action = !user.groups.exists?(domain.group_id) + + NotificationMailer.notify_record( + record: record, + context: context, + user: user, + admin: admin_action, + others: others, + changes: changes + ).deliver + end + + def handle_domain(data) # rubocop:disable Metrics/MethodLength + domain, context, user = data.values_at(:object, :context, :user) + changes = domain.previous_changes + + # Nobody is interested in those + changes.delete('updated_at') + changes.delete('created_at') + return if changes.empty? + + others = domain.group.users.where.not(id: user.id).pluck(:email) + return if others.empty? + + admin_action = !user.groups.exists?(domain.group_id) + NotificationMailer.notify_domain( + domain: domain, + context: context, + user: user, + admin: admin_action, + others: others, + changes: changes + ).deliver + end +end diff --git a/test/mailers/notification_mailer_test.rb b/test/mailers/notification_mailer_test.rb new file mode 100644 index 0000000..33a9d40 --- /dev/null +++ b/test/mailers/notification_mailer_test.rb @@ -0,0 +1,146 @@ +require 'test_helper' + +class NotificationMailerTest < ActionMailer::TestCase + + class DomainNotificationMailerTest < ActionMailer::TestCase + def setup + @notification = Notification.instance + + @group = create(:group_with_users) + @domain = create(:domain, group: @group) + @record = build(:a, name: 'a', domain: @domain) + end + + test 'domain add' do + @record.save! + + @notification.notify_domain(@group.users.first, @domain, :create) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Created' + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + end + + test 'domain edit' do + @record.save! + + @domain.type = 'SLAVE' + @domain.master = '1.2.3.4' + @domain.save! + + @notification.notify_domain(@group.users.first, @domain, :update) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Modified' + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + assert_includes mail.body.to_s, 'type from NATIVE' + assert_includes mail.body.to_s, 'type to SLAVE' + assert_includes mail.body.to_s, 'master from (empty)' + assert_includes mail.body.to_s, 'master to 1.2.3.4' + end + + test 'domain destroy' do + @record.save! + + @domain.destroy! + + @notification.notify_domain(@group.users.first, @domain, :destroy) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Deleted' + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + end + end + + class DomainNotificationMailerTest < ActionMailer::TestCase + test 'record add' do + @record.save! + + @notification.notify_record(@group.users.first, @record, :create) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Created' + assert_includes mail.body.to_s, "Record: #{@record.name}" + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "State: #{@record.to_dns}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + end + + test 'record edit' do + @record.save! + + prev_content = @record.content + @record.content = '1.1.1.1' + @record.save! + + @notification.notify_record(@group.users.first, @record, :update) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Modified' + assert_includes mail.body.to_s, "Record: #{@record.name}" + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "State: #{@record.to_dns}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + assert_includes mail.body.to_s, "content from #{prev_content}" + assert_includes mail.body.to_s, 'content to 1.1.1.1' + end + + test 'soa edit' do + @record = @domain.soa + + prev_content = @record.content + @record.nx = 10 + @record.save! + + @notification.notify_record(@group.users.first, @record, :update) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Modified' + assert_includes mail.body.to_s, "Record: #{@record.name}" + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "State: #{@record.to_dns}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + assert_includes mail.body.to_s, "content from #{prev_content}" + assert_includes mail.body.to_s, "content to #{@record.content}" + assert_includes mail.body.to_s, ' 10' + end + + test 'record destroy' do + @record.save! + + @record.destroy! + + @notification.notify_record(@group.users.first, @record, :destroy) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + + assert_equal [@group.users.last.email], mail.to + assert_includes mail.subject, 'Deleted' + assert_includes mail.body.to_s, "Record: #{@record.name}" + assert_includes mail.body.to_s, "Domain: #{@domain.name}" + assert_includes mail.body.to_s, "By: #{@group.users.first.email}" + end + end +end