Page Menu
Home
GRNET
Search
Configure Global Search
Log In
Files
F1299955
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Subscribers
None
File Metadata
Details
File Info
Storage
Attached
Created
Wed, Nov 19, 5:29 AM
Size
60 KB
Mime Type
text/x-diff
Expires
Fri, Nov 21, 5:29 AM (20 h, 28 m)
Engine
blob
Format
Raw Data
Handle
294763
Attached To
rWEBDNS WebDNS (edet4)
View Options
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? %>
<div class="jumbotron">
<h2>Wellcome to WebDNS!</h2>
<p>
In order to manage domains you have to be a member of a group.
</p>
<p>
You can either contact an admin to create a new group for you, or ask another user for an invite to an existing group.
</p>
</div>
<% end %>
<div class="datatable-wrapper">
<table id="domains" class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th>Serial</th>
<th>Group</th>
<th>State</th>
<th>Slave</th>
<th>DNSSEC</th>
<th class="no-order-and-search">Controls</th>
</tr>
</thead>
<tbody>
<% @domains.group_by(&:group).each do |group, domains| %>
<% domains.each do |domain| %>
<tr class="group-<%= group.id =%>">
<td><%= link_to domain.name, domain %></td>
<td><%= domain.serial %></td>
<td><%= link_to group.name, group_path(group) %></td>
<td><%= human_state(domain.state) %></td>
<td><%= domain.slave? ? domain.master : '-' %></td>
<td><%= domain.dnssec? ? 'secure' : '-' %></td>
<td>
<%= 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? %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
<p>
<% 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 %>
</p>
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 %>
<li>
<%= link_to_edit edit_admin_group_path(@group) %>
<%= link_to_destroy admin_group_path(@group), method: :delete, data: { confirm: 'Are you sure?' } %>
</li>
<% end if admin? %>
<ul class="nav nav-tabs">
<li role="presentation" class="active"><a id="tab-link-domains" data-toggle="tab" href="#domains_tab">Domains</a></li>
<li role="presentation"><a id="tab-link-members" data-toggle="tab" href="#members_tab">Members</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="domains_tab">
<table id="domains" class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th>Serial</th>
<th>Group</th>
<th>State</th>
<th>Slave</th>
<th>DNSSEC</th>
<th class="no-order-and-search">Controls</th>
</tr>
</thead>
<tbody>
<% @domains.group_by(&:group).each do |group, domains| %>
<% domains.each do |domain| %>
<tr class="group-<%= group.id =%>">
<td><%= link_to domain.name, domain %></td>
<td><%= domain.serial %></td>
<td><%= link_to group.name, group_path(group) %></td>
<td><%= human_state(domain.state) %></td>
<td><%= domain.slave? ? domain.master : '-' %></td>
<td><%= domain.dnssec? ? 'secure' : '-' %></td>
<td>
<%= 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? %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<p>
<% 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 %>
</p>
</div>
<div role="tabpanel" class="tab-pane" id="members_tab">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Member</th>
<th>Controls</th>
</tr>
</thead>
<tbody>
<% @group.memberships.includes(:user).each do |membership| %>
<tr>
<td><%= membership.user.email %><%= " (you)" if current_user == membership.user %></td>
<td>
<%= link_to_destroy destroy_member_group_path(@group, membership.user_id), method: :delete %>
</td>
</tr>
<% end %>
</tbody>
</table>
<p>
<%= 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 %>
</p>
</div>
</div>
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 @@
<!-- Fixed navbar -->
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">WebDNS</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li class="active"><a href="/domains">Domains</a></li>
<form class="navbar-form navbar-left" role="search" action="/records/search" method="get">
<div class="form-group">
<input type="text" name="q" id="q" class="form-control" placeholder="Records" value="<%= params[:q] %>">
</div>
<button type="submit" class="btn btn-default">Search</button>
</form>
</ul>
<ul class="nav navbar-nav navbar-right">
<% if admin? %>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Admin<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="/admin/groups/">Groups</a></li>
<li><a href="/admin/jobs/">Jobs</a></li>
<li><a href="/admin/users/orphans/">Orphans</a></li>
</ul>
</li>
<% end %>
<% if user_signed_in? %>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<%= current_user.try(:email) %>
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<% if current_user.can_change_password? %>
<li><%= link_to('Change Password', edit_user_registration_path) %></li>
<% end %>
+ <% if current_user.notifications? %>
+ <li><%= link_to('Mute notifcications', mute_user_domains_path(current_user), method: :put,
+ title: 'Mute all domain notifications') %></li>
+ <% else %>
+ <li><%= link_to('Unmute notifications', unmute_user_domains_path(current_user), method: :put,
+ title: 'Unmute all domain notifications') %></li>
+ <% end %>
<li><%= link_to('API Token', token_user_path(current_user)) %></li>
<li><%= link_to('Logout', destroy_user_session_path, method: :delete) %></li>
</ul>
</li>
<% end %>
</ul>
</div><!--/.nav-collapse -->
</div>
</nav>
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
Event Timeline
Log In to Comment