diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 3efd037..7f63099 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1,51 +1,55 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
* or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any styles
* defined in the other CSS/SCSS files in this directory. It is generally better to create a new
* file per style scope.
*
*= require bootstrap.min
*= require dataTables.bootstrap.min
*= require bootstrap-editable
*= require_tree .
*= require_self
*/
/* Make sure navbar does not overlay body */
body {
padding-top: 70px;
}
.highlight , .highlight>td {
background-color: #b8e0b8 !important;
}
/* DataTable resets bootstrap's margin-bottom */
.datatable-wrapper {
margin-bottom: 20px;
}
/* Reset bootstrap's help cursor on control links */
table a abbr[title] {
cursor: pointer;
}
.tab-pane table {
margin-top: 20px;
}
#inline-record-form #record_ttl {
width: 80px;
}
#inline-record-form #record_prio {
width: 80px;
}
#inline-record-form #record_content {
width: 300px;
}
+
+#domains span.glyphicon-volume-up {
+ color: red;
+}
diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb
index 5c9903d..35f001f 100644
--- a/app/controllers/domains_controller.rb
+++ b/app/controllers/domains_controller.rb
@@ -1,104 +1,107 @@
+require 'set'
+
class DomainsController < ApplicationController
before_action :authenticate_user!
before_action :domain, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy]
before_action :group, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy]
helper_method :edit_group_scope
# GET /domains
def index
@domains = show_domain_scope.includes(:group, :soa).all
+ @optouts = Set.new current_user.subscriptions.pluck(:domain_id)
end
# GET /domains/1
def show
@record = Record.new(domain_id: @domain.id)
end
# GET /domains/new
def new
@domain = Domain.new(new_domain_params)
end
# GET /domains/1/edit
def edit
end
# GET /domains/1/edit_dnssec
def edit_dnssec
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
if domain_params[:dnssec] # DNSSEC form
render :edit_dnssec
else
render :edit
end
end
end
# DELETE /domains/1
def destroy
if @domain.remove
notify_domain(@domain, :destroy)
redirect_to domains_url, notice: "#{@domain.name} is scheduled for removal."
else
redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')"
end
end
# DELETE /domains/1/full_destroy
def full_destroy
if @domain.full_remove
notify_domain(@domain, :destroy)
redirect_to domains_url,
notice: "#{@domain.name} is scheduled for full removal. DS records will be dropped from the parent zone before proceeding"
else
redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')"
end
end
private
def group
domain.group
end
def new_domain_params
params.permit(:group_id)
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)
# Sometimes domain name might contain whitespace, make sure we remove
# them. Note that we use a regex to handle unicode whitespace characters as well.
d[:name] = d[:name].gsub(/\p{Space}/, '') if d[:name]
}.permit(:name, :type, :master, :group_id,
:dnssec, :dnssec_parent, :dnssec_parent_authority, :dnssec_policy_id)
end
def notify_domain(*args)
notification.notify_domain(current_user, *args) if WebDNS.settings[:notifications]
end
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 0d0c3eb..d22f844 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -1,53 +1,54 @@
class GroupsController < ApplicationController
before_action :authenticate_user!
before_action :group, only: [:show, :create_member, :destroy_member, :search_member]
before_action :user, only: [:destroy_member]
# GET /groups/1
def show
@domains = @group.domains
+ @optouts = Set.new current_user.subscriptions.where(domain_id: @domains).pluck(:domain_id)
end
# POST /groups/1/members/
def create_member
@user = User.find_by_email(params[:email])
if !@user
redirect_to group_path(@group, anchor: 'tab-members'), alert: "User '#{params[:email]}' not found!"
return
end
membership = @group.memberships.find_or_create_by!(user_id: @user.id)
redirect_to group_path(@group, anchor: 'tab-members'), notice: "#{membership.user.email} is now a member of #{@group.name}"
end
# DELETE /groups/1/member/1
def destroy_member
membership = @group.memberships.find_by!(user_id: user.id)
membership.destroy!
redirect_to @group, notice: "#{membership.user.email} was successfully removed from #{@group.name}"
end
def search_member
results = []
if params[:q].present?
uids = group.users.pluck(:id)
results = User
.where('email like ?', "#{params[:q]}%")
.where.not(id: uids) # Exclude group members
.limit(10)
end
render json: results.map { |r| Hash[:id, r.id, :email, r.email] }
end
private
def user
@user ||= User.find(params[:user_id])
end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index ef79c33..5ae40dd 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,31 +1,64 @@
class UsersController < ApplicationController
before_action :authenticate_user!
- before_action :user, only: [:token, :generate_token]
+ before_action :user, only: [:mute, :unmute, :mute_all, :unmute_all, :token, :generate_token]
# GET /users/1/token
def token
end
# POST /users/1/generate_token
def generate_token
@user.token = SecureRandom.hex(10)
@user.save!
redirect_to token_user_path(@user)
end
+ # PUT /users/1/unsubscribe/2
+ def mute
+ domain = show_domain_scope.find(params[:domain_id])
+ @user.subscriptions.find_or_create_by!(domain: domain)
+
+ redirect_to domains_url, notice: "Successfully unsubscribed from #{domain.name} notifications!"
+ end
+
+ # PUT /users/1/subscribe/2
+ def unmute
+ domain = show_domain_scope.find(params[:domain_id])
+ # Drop all opt-outs
+ @user.subscriptions.where(domain: domain).delete_all
+
+ redirect_to domains_url, notice: "Successfully subscribed to #{domain.name} notifications!"
+ end
+
+ # PUT /users/1/domains/mute
+ def mute_all
+ @user.update_column(:notifications, false)
+ @user.mute_all_domains
+
+ redirect_to domains_url, notice: "Successfully unsubscribed from all domain notifications!"
+ end
+
+ # PUT /users/1/domains/mute
+ def unmute_all
+ @user.update_column(:notifications, true)
+ @user.subscriptions.delete_all
+
+ redirect_to domains_url, notice: "Successfully unsubscribed from all domain notifications!"
+ end
+
private
def user
- @user ||= User.find(params[:id])
+ @user ||= User.find(params[:user_id] || params[:id])
# Guard access to other user tokens
if current_user.id != @user.id && !admin?
redirect_to(root_path, alert: 'You need admin rights for that!')
end
@user
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index b48b410..7a7b4d0 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,57 +1,65 @@
module ApplicationHelper
TIME_PERIODS = {
1.second => 'second',
1.minute => 'minute',
1.hour => 'hour',
1.day => 'day',
1.week => 'week',
1.month => 'month',
1.year.to_i => 'year',
}
def can_edit?(object)
return true unless object.respond_to?(:editable?)
by = admin? ? :admin : :user
object.editable?(by)
end
def seconds_to_human(seconds)
acc = {}
remaining = seconds
TIME_PERIODS.to_a.reverse_each do |p, human|
period_count, remaining = remaining.divmod(p)
acc[human] = period_count if not period_count.zero?
end
acc.map { |singular, count|
human = count < 2 ? singular : "#{singular}s"
"#{count} #{human}"
}.join(', ')
end
def link_to_edit(*args, &block)
link_to(abbr_glyph(:pencil, 'Edit'), *args, &block)
end
def link_to_destroy(*args, &block)
link_to(abbr_glyph(:remove, 'Remove'), *args, &block)
end
def link_to_enable(*args, &block)
link_to(abbr_glyph(:'eye-close', 'Enable'), *args, &block)
end
def link_to_disable(*args, &block)
link_to(abbr_glyph(:'eye-open', 'Disable'), *args, &block)
end
+ def link_to_mute(*args, &block)
+ link_to(abbr_glyph(:'volume-off', 'Disable notifications'), *args, &block)
+ end
+
+ def link_to_unmute(*args, &block)
+ link_to(abbr_glyph(:'volume-up', 'Renable notifications'), *args, &block)
+ end
+
def glyph(icon)
content_tag(:span, '', class: "glyphicon glyphicon-#{icon}")
end
def abbr_glyph(icon, title)
content_tag(:abbr, glyph(icon), title: title)
end
end
diff --git a/app/models/domain.rb b/app/models/domain.rb
index 25e4c52..56e3fe5 100644
--- a/app/models/domain.rb
+++ b/app/models/domain.rb
@@ -1,392 +1,401 @@
class Domain < ActiveRecord::Base
class NotAChild < StandardError; end
self.inheritance_column = :nx
# List all supported domain types.
def self.domain_types
[
'NATIVE',
'MASTER',
'SLAVE',
]
end
# List domain types that can be created.
def self.allowed_domain_types
domain_types - WebDNS.settings[:prohibit_domain_types]
end
# List parent authorities
def self.dnssec_parent_authorities
WebDNS.settings[:dnssec_parent_authorities].keys.map(&:to_s)
end
# Fire event after transaction commmit
# Changing state inside a hook messes things up,
# this trick handles that
attr_accessor :fire_event
belongs_to :group
has_many :jobs
+ has_many :opt_outs, class_name: 'Subscription', dependent: :delete_all
+
has_many :records
# BUG in bump_serial_trigger
has_one :soa, -> { unscope(where: :type).where(type: 'soa') }, class_name: SOA
belongs_to :dnssec_policy
validates :group_id, presence: true
validates :name, uniqueness: true, presence: true
validates :type, presence: true, inclusion: { in: domain_types }
validates :master, presence: true, ipv4: true, if: :slave?
validates :dnssec, inclusion: { in: [false] }, unless: :dnssec_elegible?
validates :dnssec_parent_authority, inclusion: { in: dnssec_parent_authorities }, if: :dnssec?
validates :dnssec_parent, hostname: true, if: :dnssec?
validates :dnssec_policy_id, presence: true, if: :dnssec?
after_create :generate_soa
after_create :generate_ns
+ after_create :generate_subscriptions
after_create :install
before_save :check_convert
before_save :check_dnssec_parent_authority, if: :dnssec?
after_commit :after_commit_event
attr_writer :serial_strategy
def self.dnssec_progress(current_state)
progress = [
:pending_signing, # 1/3
:wait_for_ready, # 2/3
:pending_ds] # 3/3
idx = progress.index(current_state.to_sym)
return if idx.nil?
[idx+1, progress.size].join('/')
end
state_machine initial: :initial do
after_transition(any => :pending_install) { |domain, _t| Job.add_domain(domain) }
after_transition(any => :pending_remove) { |domain, _t| Job.shutdown_domain(domain) }
after_transition(any => :pending_ds_removal) { |domain, _t| Job.dnssec_drop_ds(domain) }
after_transition(any => :pending_signing) { |domain, _t| Job.dnssec_sign(domain) }
after_transition(any => :wait_for_ready) { |domain, _t| Job.wait_for_ready(domain) }
after_transition(any => :pending_ds) { |domain, t| Job.dnssec_push_ds(domain, *t.args) }
after_transition(any => :pending_ds_rollover) { |domain, t| Job.dnssec_rollover_ds(domain, *t.args) }
after_transition(any => :pending_plain) { |domain, _t| Job.convert_to_plain(domain) }
after_transition(any => :destroy) { |domain, _t| domain.destroy }
# User events
event :install do
transition initial: :pending_install
end
event :dnssec_sign do
transition operational: :pending_signing
end
event :signed do
transition pending_signing: :wait_for_ready
end
event :push_ds do
transition wait_for_ready: :pending_ds, operational: :pending_ds_rollover
end
event :plain_convert do
transition operational: :pending_plain
end
event :remove do
transition [:operational, :pending_ds_removal] => :pending_remove
end
event :full_remove do
transition operational: :pending_ds_removal
end
# Machine events
event :installed do
transition pending_install: :operational
end
event :converted do
transition [:pending_ds, :pending_plain] => :operational
end
event :complete_rollover do
transition pending_ds_rollover: :operational
end
event :cleaned_up do
transition pending_remove: :destroy
end
event :ksk_rollover_detected do
transition operational: :ksk_rollover
end
end
# Returns true if this domain is elegigble for DNSSEC
def dnssec_elegible?
return false if slave?
true
end
# Returns the zone serial if a SOA record exists
def serial
return if !soa
soa.serial
end
# Get the zone's serial strategy.
#
# Returns one of the supported serial strategies.
def serial_strategy
@serial_strategy ||= WebDNS.settings[:serial_strategy]
end
# Returns true if this a reverse zone.
def reverse?
name.end_with?('.in-addr.arpa') || name.end_with?('.ip6.arpa')
end
# Returns true if this a ENUM zone.
def enum?
name.end_with?('.e164.arpa')
end
# Returns true if this is a slave zone.
def slave?
type == 'SLAVE'
end
def to_export
Hash[
:id, id,
:name, name,
:group, group.name,
].with_indifferent_access
end
def to_api
Hash[
:name, name,
:slave, slave?,
:group, group.name,
].with_indifferent_access
end
# Compute subnet for reverse records
def subnet
return if not reverse?
if name.end_with?('.in-addr.arpa')
subnet_v4
elsif name.end_with?('.ip6.arpa')
subnet_v6
end
end
def self.replace_ds(parent, child, records)
records ||= []
parent = find_by_name!(parent)
fail NotAChild if not child.end_with?(parent.name)
existing = parent.records.where(name: child, type: 'DS')
recs = records.map { |rec| DS.new(domain: parent, name: child, content: rec) }
ActiveRecord::Base.transaction do
existing.destroy_all
recs.map(&:save!)
end
end
# Apply api bulk to operations to the zone
#
# 1) Deletions
# 2) Upserts
# 3) Additions
def api_bulk(opts)
api_deletes = opts[:deletes] || []
api_upserts = opts[:upserts] || []
api_additions = opts[:additions] || []
api_delete_errors = {}
deletes = []
additions = {}
api_deletes.each { |del|
rec = records.find_by(del)
# Fail-fast if record doesn't exist
if rec.nil?
return [{}, { deletes: { del: 'record not found'}}]
end
deletes << rec.id
}
# We delete records matching the same name & type
api_upserts.each { |ups|
query = ups.slice(:name, :type)
existing = records.where(query).to_a
# Skip upsert if we are trying to save the same record
next if existing.one? && ups.all? { |k, v| existing.first.to_api[k] == v }
deletes += existing.map(&:id)
api_additions << ups
}
api_additions.each { |add|
additions[add] = add
}
ops, errors = bulk(deletes: deletes, additions: additions)
# Serialize the response for API
api_ops = {}
api_errors = {}
# ops
ops.each { |op, recs| api_ops[op] = recs.map(&:to_api) }
# errors
if errors.any?
errors.each { |op, err|
api_errors[op] = err.map { |rec, err|
{ operation: rec, error: err }
}
}
end
# This is a bit ugly, we return an ops hash with the original bulk
# responses so we can feed it to record notification.
[api_ops, api_errors, ops]
end
# Apply bulk to operations to the zones
#
# 1) Deletions
# 2) Changes
# 3) Additions
def bulk(opts)
deletes = opts[:deletes] || []
changes = opts[:changes] || {}
additions = opts[:additions] || {}
errors = Hash.new { |h, k| h[k] = {} }
operations = Hash.new { |h, k| h[k] = [] }
ActiveRecord::Base.transaction do
# Deletes
to_delete = records.where(id: deletes).index_by(&:id)
deletes.each { |rec_id|
if rec = to_delete[Integer(rec_id)]
rec.destroy
operations[:deletes] << rec
next
end
errors[:deletes][rec_id] = 'Deleted record not found'
}
# Changes
to_change = records.where(id: changes.keys).index_by(&:id)
changes.each {|rec_id, changes|
if rec = to_change[Integer(rec_id)]
operations[:changes] << rec
errors[:changes][rec_id] = rec.errors.full_messages.join(', ') if !rec.update(changes)
next
end
errors[:changes][rec_id] = 'Changed record not found'
}
# Additions
additions.each { |inc, attrs|
rec = records.new(attrs)
operations[:additions] << rec
errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save
}
raise ActiveRecord::Rollback if errors.any?
end
[operations, errors]
end
private
def subnet_v4
# get ip octets (remove .in-addr.arpa)
octets = name.split('.')[0...-2].reverse
return if octets.any? { |_| false }
mask = 8 * octets.size
octets += [0, 0, 0, 0]
ip = IPAddr.new octets[0, 4].join('.')
[ip, mask].join('/')
end
def subnet_v6
nibbles = name.split('.')[0...-2].reverse
return if nibbles.any? { |_| false }
mask = 4 * nibbles.size
nibbles += [0] * 32
ip = IPAddr.new nibbles[0, 32].in_groups_of(4).map(&:join).join(':')
[ip, mask].join('/')
end
# Hooks
def generate_soa
soa_record = SOA.new(domain: self)
soa_record.save!
end
def generate_ns
return if slave?
return if WebDNS.settings[:default_ns].empty?
WebDNS.settings[:default_ns].each { |ns|
Record.find_or_create_by!(domain: self, type: 'NS', name: '', content: ns)
}
end
+ def generate_subscriptions
+ group.users.where(notifications: false).each { |u|
+ opt_outs.create(user: u)
+ }
+ end
+
def check_convert
return if !dnssec_changed?
event = dnssec ? :dnssec_sign : :plain_convert
if state_events.include?(event)
self.fire_event = event # Schedule event for after commit
return true
end
errors.add(:dnssec, 'You cannot modify dnssec settings in this state!')
false
end
def check_dnssec_parent_authority
cfg = WebDNS.settings[:dnssec_parent_authorities][dnssec_parent_authority.to_sym]
return if !cfg[:valid]
return true if cfg[:valid].call(dnssec_parent)
errors.add(:dnssec_parent_authority, 'Parent zone is not accepted for the selected parent authority!')
false
end
def after_commit_event
return if !fire_event
fire_state_event(fire_event)
self.fire_event = nil
end
end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 0000000..476954d
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,11 @@
+class Subscription < ActiveRecord::Base
+ belongs_to :domain
+ belongs_to :user
+
+ validates_presence_of :domain
+ validates_presence_of :user
+ validates_uniqueness_of :domain_id, scope: :user_id
+
+ # opt-out only
+ validates :disabled, inclusion: { in: [true] }, presence: true
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index b9f1cbe..66f7508 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,30 +1,48 @@
class User < ActiveRecord::Base
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
has_many :memberships
has_many :groups, through: :memberships
+ has_many :subscriptions, dependent: :delete_all
+
scope :orphans, -> { includes(:memberships).where(:memberships => { user_id: nil }) }
# Check if the user can change his password
#
# Remote users are not able to change their password
def can_change_password?
!identifier?
end
def to_api
Hash[
:id, id,
:email, email
].with_indifferent_access
end
def self.find_for_database_authentication(conditions)
# Override devise method for database auth
# We only want to auth local user via the database.
find_first_by_auth_conditions(conditions, identifier: '')
end
+
+ def mute_all_domains
+ ActiveRecord::Base.transaction do
+ domain_ids = Domain.where(group: groups).pluck(:id)
+ domain_ids.each { |did|
+
+ sub = self.subscriptions.create(domain_id: did)
+ if !sub.valid?
+ # Allow only domain_id (uniqueness) errors
+ raise x.errors.full_messages.join(', ') if sub.errors.size > 1
+ raise x.errors.full_messages.join(', ') if !sub.errors[:domain_id]
+ end
+
+ }
+ end
+ end
end
diff --git a/app/views/domains/index.html.erb b/app/views/domains/index.html.erb
index a95799f..22beec0 100644
--- a/app/views/domains/index.html.erb
+++ b/app/views/domains/index.html.erb
@@ -1,56 +1,61 @@
<% if current_user.memberships.empty? %>
Wellcome to WebDNS!
In order to manage domains you have to be a member of a group.
You can either contact an admin to create a new group for you, or ask another user for an invite to an existing group.
<% end %>
Domain |
Serial |
Group |
State |
Slave |
DNSSEC |
Controls |
<% @domains.group_by(&:group).each do |group, domains| %>
<% domains.each do |domain| %>
<%= link_to domain.name, domain %> |
<%= domain.serial %> |
<%= link_to group.name, group_path(group) %> |
<%= human_state(domain.state) %> |
<%= domain.slave? ? domain.master : '-' %> |
<%= domain.dnssec? ? 'secure' : '-' %> |
<%= link_to_edit edit_domain_path(domain) %>
+ <% if @optouts.include? domain.id %>
+ <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %>
+ <% else %>
+ <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %>
+ <% end %>
<%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %>
<%= link_to_full_destroy full_destroy_domain_path(domain),
method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>
|
<% end %>
<% end %>
<% if current_user.memberships.any? %>
<%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary' %>
<% else %>
<%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary disabled' %>
<% end %>
diff --git a/app/views/groups/show.html.erb b/app/views/groups/show.html.erb
index 7874355..d81de70 100644
--- a/app/views/groups/show.html.erb
+++ b/app/views/groups/show.html.erb
@@ -1,87 +1,92 @@
<% content_for :more_breadcrumbs do %>
<%= link_to_edit edit_admin_group_path(@group) %>
<%= link_to_destroy admin_group_path(@group), method: :delete, data: { confirm: 'Are you sure?' } %>
<% end if admin? %>
Domain |
Serial |
Group |
State |
Slave |
DNSSEC |
Controls |
<% @domains.group_by(&:group).each do |group, domains| %>
<% domains.each do |domain| %>
<%= link_to domain.name, domain %> |
<%= domain.serial %> |
<%= link_to group.name, group_path(group) %> |
<%= human_state(domain.state) %> |
<%= domain.slave? ? domain.master : '-' %> |
<%= domain.dnssec? ? 'secure' : '-' %> |
<%= link_to_edit edit_domain_path(domain) %>
+ <% if @optouts.include? domain.id %>
+ <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %>
+ <% else %>
+ <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %>
+ <% end %>
<%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %>
<%= link_to_full_destroy full_destroy_domain_path(domain),
method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>
|
<% end %>
<% end %>
<% if current_user.memberships.any? %>
<%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary' %>
<% else %>
<%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary disabled' %>
<% end %>
Member |
Controls |
<% @group.memberships.includes(:user).each do |membership| %>
<%= membership.user.email %><%= " (you)" if current_user == membership.user %> |
<%= link_to_destroy destroy_member_group_path(@group, membership.user_id), method: :delete %>
|
<% end %>
<%= bootstrap_form_tag(url: create_member_group_path(@group), layout: :inline) do |f| %>
<%= f.text_field :email, prepend: 'Add Member', hide_label: true, id: 'js-search-member', data: { group: @group.id } %>
<%= f.submit 'Add', class: 'btn btn-primary' %>
<% end %>
diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb
index e05d35d..22fc114 100644
--- a/app/views/shared/_nav.html.erb
+++ b/app/views/shared/_nav.html.erb
@@ -1,57 +1,64 @@
diff --git a/config/routes.rb b/config/routes.rb
index cb736ad..b85202c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,82 +1,88 @@
Rails.application.routes.draw do
# Override devise user removal
devise_scope :users do
delete :users, to: redirect('/')
end
devise_for :users
get '/auth/saml', to: 'auth#saml'
root to: redirect('/domains')
resources :users, only: [] do
get :token, to: 'users#token', on: :member
post :generate_token, to: 'users#generate_token', on: :member
+ resources :domains, only: [] do
+ put :mute, to: 'users#mute'
+ put :unmute, to: 'users#unmute'
+ put :mute, to: 'users#mute_all', on: :collection
+ put :unmute, to: 'users#unmute_all', on: :collection
+ end
end
resources :groups, only: [:show] do
get :search_member,
to: 'groups#search_member', on: :member
post :members,
to: 'groups#create_member', as: :create_member, on: :member
delete 'member/:user_id',
to: 'groups#destroy_member', as: :destroy_member, on: :member
end
resources :domains do
get :edit_dnssec, to: 'domains#edit_dnssec', on: :member
delete :full_destroy, to: 'domains#full_destroy', on: :member
resources :records, except: [:index, :show] do
# Reuse records#update instead of introducing new controller actions
#
# rubocop:disable Style/AlignHash
put :disable, to: 'records#update', on: :member,
defaults: { record: { disabled: true } }
put :enable, to: 'records#update', on: :member,
defaults: { record: { disabled: false } }
put :editable, to: 'records#editable', on: :collection
post :valid, to: 'records#valid', on: :collection
post :bulk, to: 'records#bulk', on: :collection
# rubocop:enable Style/AlignHash
end
end
get '/records/search', to: 'records#search'
# Admin
namespace :admin do
root to: redirect('/admin/groups')
resources :groups, except: [:show]
resources :jobs, only: [:index, :destroy] do
put :done, to: 'jobs#update', on: :member,
defaults: { job: { status: 1 } }
put :pending, to: 'jobs#update', on: :member,
defaults: { job: { status: 0 } }
get '/type/:category', to: 'jobs#index', on: :collection,
constraints: proc { |req| ['completed', 'pending'].include?(req.params[:category]) }
end
resources :users, only: [:destroy] do
get :orphans, to: 'users#orphans', on: :collection
put :update_groups, to: 'users#update_groups', on: :collection
end
end
# API
scope '/api' do
get :ping, to: 'api#ping'
get :whoami, to: 'api#whoami'
get '/domain/:domain/list', to: 'api#list', constraints: { domain: /[^\/]+/}
post '/domain/:domain/bulk', to: 'api#bulk', constraints: { domain: /[^\/]+/}
get :domains, to: 'api#domains'
end if WebDNS.settings[:api]
# Private
put 'private/replace_ds', to: 'private#replace_ds'
put 'private/trigger_event', to: 'private#trigger_event'
get 'private/zones', to: 'private#zones'
get 'help/api', to: 'help#api'
end
diff --git a/db/migrate/20170305083712_create_subscriptions.rb b/db/migrate/20170305083712_create_subscriptions.rb
new file mode 100644
index 0000000..a9474af
--- /dev/null
+++ b/db/migrate/20170305083712_create_subscriptions.rb
@@ -0,0 +1,11 @@
+class CreateSubscriptions < ActiveRecord::Migration
+ def change
+ create_table :subscriptions do |t|
+ t.references :domain, index: true, null: false
+ t.references :user, index: true, null: false
+ t.boolean :disabled, default: true, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20170308060441_add_notifications_to_user.rb b/db/migrate/20170308060441_add_notifications_to_user.rb
new file mode 100644
index 0000000..554df66
--- /dev/null
+++ b/db/migrate/20170308060441_add_notifications_to_user.rb
@@ -0,0 +1,5 @@
+class AddNotificationsToUser < ActiveRecord::Migration
+ def change
+ add_column :users, :notifications, :boolean, default: true, null: false
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 1613156..073b154 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -1,318 +1,343 @@
-- MySQL dump 10.15 Distrib 10.0.20-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: webns
-- ------------------------------------------------------
-- Server version 10.0.20-MariaDB-3
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `comments`
--
DROP TABLE IF EXISTS `comments`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`type` varchar(10) NOT NULL,
`modified_at` int(11) NOT NULL,
`account` varchar(40) NOT NULL,
`comment` mediumtext NOT NULL,
PRIMARY KEY (`id`),
KEY `comments_domain_id_idx` (`domain_id`),
KEY `comments_name_type_idx` (`name`,`type`),
KEY `comments_order_idx` (`domain_id`,`modified_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `cryptokeys`
--
DROP TABLE IF EXISTS `cryptokeys`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `cryptokeys` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`flags` int(11) NOT NULL,
`active` tinyint(1) DEFAULT NULL,
`content` text,
PRIMARY KEY (`id`),
KEY `domainidindex` (`domain_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `dnssec_policies`
--
DROP TABLE IF EXISTS `dnssec_policies`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `dnssec_policies` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`active` tinyint(1) DEFAULT NULL,
`policy` text,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `domainmetadata`
--
DROP TABLE IF EXISTS `domainmetadata`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `domainmetadata` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) NOT NULL,
`kind` varchar(32) DEFAULT NULL,
`content` text,
PRIMARY KEY (`id`),
KEY `domainmetadata_idx` (`domain_id`,`kind`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `domains`
--
DROP TABLE IF EXISTS `domains`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `domains` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`master` varchar(128) DEFAULT NULL,
`last_check` int(11) DEFAULT NULL,
`type` varchar(6) NOT NULL,
`notified_serial` int(11) DEFAULT NULL,
`account` varchar(40) DEFAULT NULL,
`group_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
`state` varchar(255) NOT NULL DEFAULT 'initial',
`dnssec` tinyint(1) NOT NULL DEFAULT '0',
`dnssec_parent` varchar(255) NOT NULL DEFAULT '',
`dnssec_parent_authority` varchar(255) NOT NULL DEFAULT '',
`dnssec_policy_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name_index` (`name`),
KEY `index_domains_on_group_id` (`group_id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `groups`
--
DROP TABLE IF EXISTS `groups`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `groups` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`disabled` tinyint(1) DEFAULT '0',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `index_groups_on_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `jobs`
--
DROP TABLE IF EXISTS `jobs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `jobs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`job_type` varchar(255) NOT NULL,
`domain_id` int(11) DEFAULT NULL,
`args` varchar(255) NOT NULL,
`status` int(11) NOT NULL DEFAULT '0',
`retries` int(11) NOT NULL DEFAULT '0',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `index_jobs_on_domain_id` (`domain_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `memberships`
--
DROP TABLE IF EXISTS `memberships`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `memberships` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`group_id` int(11) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `index_memberships_on_group_id` (`group_id`),
KEY `index_memberships_on_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `records`
--
DROP TABLE IF EXISTS `records`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain_id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`type` varchar(10) DEFAULT NULL,
`content` mediumtext,
`ttl` int(11) DEFAULT NULL,
`prio` int(11) DEFAULT NULL,
`change_date` int(11) DEFAULT NULL,
`disabled` tinyint(1) DEFAULT '0',
`ordername` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL,
`auth` tinyint(1) DEFAULT '1',
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `nametype_index` (`name`,`type`),
KEY `domain_id` (`domain_id`),
KEY `recordorder` (`domain_id`,`ordername`),
CONSTRAINT `records_ibfk_1` FOREIGN KEY (`domain_id`) REFERENCES `domains` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `schema_migrations`
--
DROP TABLE IF EXISTS `schema_migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `schema_migrations` (
`version` varchar(255) NOT NULL,
UNIQUE KEY `unique_schema_migrations` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
+-- Table structure for table `subscriptions`
+--
+
+DROP TABLE IF EXISTS `subscriptions`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `subscriptions` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `domain_id` int(11) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `disabled` tinyint(1) NOT NULL DEFAULT '1',
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `index_subscriptions_on_domain_id` (`domain_id`),
+ KEY `index_subscriptions_on_user_id` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
-- Table structure for table `supermasters`
--
DROP TABLE IF EXISTS `supermasters`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `supermasters` (
`ip` varchar(64) NOT NULL,
`nameserver` varchar(255) NOT NULL,
`account` varchar(40) NOT NULL,
PRIMARY KEY (`ip`,`nameserver`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `tsigkeys`
--
DROP TABLE IF EXISTS `tsigkeys`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tsigkeys` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`algorithm` varchar(50) DEFAULT NULL,
`secret` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `namealgoindex` (`name`,`algorithm`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL DEFAULT '',
`encrypted_password` varchar(255) NOT NULL DEFAULT '',
`reset_password_token` varchar(255) DEFAULT NULL,
`reset_password_sent_at` datetime DEFAULT NULL,
`remember_created_at` datetime DEFAULT NULL,
`sign_in_count` int(11) NOT NULL DEFAULT '0',
`current_sign_in_at` datetime DEFAULT NULL,
`last_sign_in_at` datetime DEFAULT NULL,
`current_sign_in_ip` varchar(255) DEFAULT NULL,
`last_sign_in_ip` varchar(255) DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
`identifier` varchar(255) DEFAULT '',
`token` varchar(255) DEFAULT NULL,
+ `notifications` tinyint(1) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `index_users_on_email` (`email`),
UNIQUE KEY `index_users_on_reset_password_token` (`reset_password_token`),
UNIQUE KEY `index_users_on_token` (`token`),
KEY `index_users_on_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2015-11-08 12:57:51
INSERT INTO schema_migrations (version) VALUES ('20151028123326');
INSERT INTO schema_migrations (version) VALUES ('20151028123327');
INSERT INTO schema_migrations (version) VALUES ('20151031184819');
INSERT INTO schema_migrations (version) VALUES ('20151107182656');
INSERT INTO schema_migrations (version) VALUES ('20151108093333');
INSERT INTO schema_migrations (version) VALUES ('20151108105701');
INSERT INTO schema_migrations (version) VALUES ('20151207054417');
INSERT INTO schema_migrations (version) VALUES ('20151207194729');
INSERT INTO schema_migrations (version) VALUES ('20151213102322');
INSERT INTO schema_migrations (version) VALUES ('20160206083933');
INSERT INTO schema_migrations (version) VALUES ('20160214155026');
INSERT INTO schema_migrations (version) VALUES ('20160403094641');
+INSERT INTO schema_migrations (version) VALUES ('20170305083712');
+
+INSERT INTO schema_migrations (version) VALUES ('20170308060441');
+
diff --git a/lib/notification.rb b/lib/notification.rb
index 1793940..77618b6 100644
--- a/lib/notification.rb
+++ b/lib/notification.rb
@@ -1,139 +1,142 @@
require 'singleton'
class Notification
include Singleton
# Send out a notification about bulk record operations.
def notify_record_bulk(user, domain, ops)
ActiveSupport::Notifications.instrument(
'webdns.record.bulk',
user: user,
domain: domain,
ops: ops)
end
# 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_record_bulk
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_record_bulk
ActiveSupport::Notifications
.subscribe 'webdns.record.bulk' do |_name, _started, _finished, _unique_id, data|
handle_record_bulk(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)
record, context, user = data.values_at(:object, :context, :user)
domain = record.domain
changes = filter_changes(record)
return if changes.empty? && context == :update
- others = domain.group.users.pluck(:email)
+ opt_outs = domain.opt_outs.pluck(:user_id)
+ others = domain.group.users.where.not(id: opt_outs).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_record_bulk(data)
ops, domain, user = data.values_at(:ops, :domain, :user)
operations = []
operations += ops[:deletes].map { |rec| [:destroy, rec, nil] }
operations += ops[:changes].map { |rec| [:update, rec, filter_changes(rec)] }
operations += ops[:additions].map { |rec| [:create, rec, nil] }
-
- others = domain.group.users.pluck(:email)
+
+ opt_outs = domain.opt_outs.pluck(:user_id)
+ others = domain.group.users.where.not(id: opt_outs).pluck(:email)
return if others.empty?
admin_action = !user.groups.exists?(domain.group_id)
NotificationMailer.notify_record_bulk(
user: user,
admin: admin_action,
others: others,
domain: domain,
operations: operations,
).deliver
end
def handle_domain(data)
domain, context, user = data.values_at(:object, :context, :user)
changes = filter_changes(domain)
return if changes.empty? && context == :update
- others = domain.group.users.pluck(:email)
+ opt_outs = domain.opt_outs.pluck(:user_id)
+ others = domain.group.users.where.not(id: opt_outs).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
private
def filter_changes(record)
changes = record.previous_changes
# Nobody is interested in those
changes.delete('updated_at')
changes.delete('created_at')
changes
end
end
diff --git a/test/factories/domain.rb b/test/factories/domain.rb
index 6f74dd5..0da1225 100644
--- a/test/factories/domain.rb
+++ b/test/factories/domain.rb
@@ -1,29 +1,36 @@
FactoryGirl.define do
sequence(:domain) { |n| "example#{n}.com" }
factory :domain do
group
name { generate(:domain) }
serial_strategy Strategies::Date
type 'NATIVE'
end
factory :slave, parent: :domain do
type 'SLAVE'
master '1.2.3.4'
end
factory :date_domain, class: Domain do
group
name { generate(:domain) }
serial_strategy Strategies::Date
type 'NATIVE'
end
factory :v4_arpa_domain, parent: :domain do
name '2.0.192.in-addr.arpa'
end
factory :v6_arpa_domain, parent: :domain do
name '8.b.d.0.1.0.0.2.ip6.arpa'
end
+
+ factory :domain_with_subscriptions, parent: :domain do
+ association :group, factory: :group_with_users
+ after(:create) do |domain|
+ Subscription.create(domain: domain, user:domain.group.users.first)
+ end
+ end
end
diff --git a/test/mailers/notification_mailer_test.rb b/test/mailers/notification_mailer_test.rb
index 5805c3c..96ab23d 100644
--- a/test/mailers/notification_mailer_test.rb
+++ b/test/mailers/notification_mailer_test.rb
@@ -1,175 +1,189 @@
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 'skip users with opt-out notifications' do
+ @record.save!
+
+ # Opt out
+ author = @group.users.first
+ Subscription.create!(user: author, domain: @domain)
+
+ @notification.notify_domain(@group.users.first, @domain, :create)
+
+ assert_not ActionMailer::Base.deliveries.empty?
+ mail = ActionMailer::Base.deliveries.last
+ assert_equal @group.users.pluck(:email) - [author.email], mail.to
+ 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 mail.to, @group.users.pluck(:email)
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 mail.to, @group.users.pluck(:email)
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 mail.to, @group.users.pluck(:email)
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 mail.to, @group.users.pluck(:email)
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 mail.to.sort, @group.users.pluck(:email)
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 mail.to, @group.users.pluck(:email)
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 mail.to, @group.users.pluck(:email)
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
test 'bulk operations' do
a = create(:a, domain: @domain)
aaaa = create(:aaaa, domain: @domain)
new = build(:mx, domain: @domain)
changes = {}.tap { |c|
c[:deletes] = [a.id]
c[:changes] = { aaaa.id => { content: '::42' }}
c[:additions] = { 1 => new.as_bulky_json }
}
ops, err = @domain.bulk(changes)
assert_empty err
@notification.notify_record_bulk(@group.users.first, @domain, ops)
assert_not ActionMailer::Base.deliveries.empty?
mail = ActionMailer::Base.deliveries.last
assert_equal mail.to, @group.users.pluck(:email)
assert_includes mail.subject, 'Bulk'
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, "Action: destroy"
assert_includes mail.body.to_s, "Action: update"
assert_includes mail.body.to_s, "Action: create"
end
end
end
diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb
new file mode 100644
index 0000000..080ce30
--- /dev/null
+++ b/test/models/subscription_test.rb
@@ -0,0 +1,48 @@
+require 'test_helper'
+
+class SubscriptionTest < ActiveSupport::TestCase
+
+ test 'single subscription for a domain' do
+ domain = create(:domain_with_subscriptions)
+ assert_equal 1, domain.opt_outs.count
+
+ subscription = domain.opt_outs.first
+ assert_equal true, subscription.disabled
+
+ user = subscription.user
+ user.reload
+
+ assert_equal domain, user.subscriptions.first.domain
+ end
+
+ test 'mute new domains on users with persistent opt-out setting' do
+ g = create(:group_with_users)
+
+ u = g.users.first
+ u.notifications = false
+ u.save!
+
+ d = create(:domain, group: g)
+
+ # Opt out subscription should be set
+ assert_equal 1, u.subscriptions.where(domain: d).count
+ end
+
+ test 'mute all domains for a user' do
+ d1 = create(:domain_with_subscriptions)
+ d2 = create(:domain_with_subscriptions)
+ user = create(:user)
+
+ # Add user to the groups
+ d1.group.users << user
+ d2.group.users << user
+
+ # Opt out from notifications
+ user.mute_all_domains
+ # Ensure this is re-entrant
+ user.mute_all_domains
+
+ # Assert 2 opt-out domains
+ assert_equal 2, user.subscriptions.count
+ end
+end