diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ff601b5..baf6ed1 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,129 +1,145 @@ // This is a manifest file that'll be compiled into application.js, which will include all the files // listed below. // // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. // // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the // compiled file. // // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details // about supported directives. // //= require jquery.min //= require jquery_ujs //= require bootstrap.min //= require typeahead.bundle.min //= require jquery.dataTables.min //= require dataTables.bootstrap.min //= require bootstrap-editable.min +//= require bulky //= require_tree . $(function() { // Setup X-Editable $.fn.editable.defaults.mode = 'inline'; $.fn.editable.defaults.ajaxOptions = { type: 'put', dataType: 'json' }; function editable_record_success(response, _value) { - // Visual hint for the changed serial - if(response.serial) { + // Visual hint for the changed serial (on non bulky updates) + if(response.saved && response.serial) { $('tr.soa .soa-serial').text(response.serial); $('tr.soa .soa-serial').fadeOut(100).fadeIn(500); } + // Add bulk update to Bulky + if (!response.saved) { + bulky.update(response.record.id, response.attribute, response.value); + } + // Render the value returned by the server as // there are cases where the value is normalized (e.x. name) return { newValue: response.value }; } $('table .editable').editable({ - success: editable_record_success + success: editable_record_success, + params: function (params) { + // Don't actually save on bulky mode + params.save = !bulky.enabled; + return params; + }, + validate: function (value) { + rec_id = $(this).parents('tr').data('id'); + if (bulky.enabled && bulky.marked_for_delete(rec_id)) + return "This record is marked for deletion!"; + } }); // Show priority on MX/SRV record only $('#record_type').change(function() { if ($(this).val() == 'MX') { // MX, default priority 10 $('#record_prio.autohide').parents('div.form-group').removeClass('hidden'); $('#record_prio.autodisable').prop('disabled', false); $('#record_prio').val('10'); } else if ($(this).val() == 'SRV') { // SRV $('#record_prio').val(''); $('#record_prio.autohide').parents('div.form-group').removeClass('hidden'); $('#record_prio.autodisable').prop('disabled', false); } else { $('#record_prio').val(''); $('#record_prio.autohide').parents('div.form-group').addClass('hidden'); $('#record_prio.autodisable').prop('disabled', true); } }); // Show master only on SLAVE domains $('#domain_type').change(function() { if ($(this).val() == 'SLAVE') { $('#domain_master').parents('div.form-group').removeClass('hidden'); } else { $('#domain_master').parents('div.form-group').addClass('hidden'); } }); // Disable DNSSEC options $('#domain_dnssec').change(function() { if ($(this).val()== 'true') { $("#dnssec_fieldset").prop('disabled', false) } else { $("#dnssec_fieldset").prop('disabled', true); } }); var searchMembersGroup = $('#js-search-member').data('group'); var searchMembers = new Bloodhound({ datumTokenizer: Bloodhound.tokenizers.obj.whitespace('email'), queryTokenizer: Bloodhound.tokenizers.whitespace, identify: function(obj) { return obj.id; }, remote: { url: '/groups/' + searchMembersGroup + '/search_member.json?q=%QUERY', wildcard: '%QUERY' } }); $('#js-search-member').typeahead({ hint: true, minLength: 2 }, { name: 'members', display: 'email', source: searchMembers }); // Highlighter helper // // Applies 'highlight' class to the element followed by 'hl-' prefix function highlighter() { $('.highlight').removeClass('highlight'); if (!window.location.hash) return; if (window.location.hash.indexOf('#hl-') == 0) { var id = window.location.hash.slice('hl-'.length + 1); $('#' + id).addClass('highlight'); } } $(window).bind('hashchange', highlighter); highlighter(); $('table#domains').DataTable({ paging: false, columnDefs: [{ targets: 'no-order-and-search', orderable: false, searchable: false }], }); }); diff --git a/app/assets/javascripts/bulky.coffee b/app/assets/javascripts/bulky.coffee new file mode 100644 index 0000000..a3cd8df --- /dev/null +++ b/app/assets/javascripts/bulky.coffee @@ -0,0 +1,177 @@ +class window.Bulky + constructor: (valid_record_url, submit_url) -> + @enabled = false + @deletes = {} + @changes = {} + @additions = {} + @add_counter = 0 + + @valid_record_url = valid_record_url + @submit_url = submit_url + + @hooked = false + @hook() + + panel_update: () -> + added = (a for own a of @additions).length + changed = (c for own c of @changes).length + deleted = (d for own d of @deletes).length + + $('#bulk-panel .added').text(added) + $('#bulk-panel .changed').text(changed) + $('#bulk-panel .deleted').text(deleted) + + marked_for_delete: (id) -> + return @deletes[id] + + update: (id, attr, value) -> + @changes[id] or= {} + @changes[id][attr] = value + $("#record-#{id}").addClass('modified') + + @panel_update() + + add: (rec) -> + @add_counter += 1 + @additions[@add_counter] = rec + + @panel_update() + @add_counter + + validate_add: (arr) -> + _me = this + $.ajax { + url: @valid_record_url, + type: 'post', + dataType: 'json', + data: arr, + success: (data) -> + if data.errors + alert(data.errors) + return + + id = _me.add(data.record) + _me.render_new(id, data.record) + error: () -> + alert('There was an error processing your request...') + } + + render_new: (id, rec) -> + el = $('#new_records .clone').clone() + el.removeClass('clone').removeClass('hidden') + + el.attr('id', "fresh-#{id}") + for attr in ['name', 'ttl', 'type', 'prio', 'content'] + if rec[attr] + el.find(".#{attr}").text(rec[attr]) + + + el.find('a.js-destroy').data('id', id).data('fresh', true) + $('#new_records tbody').append(el) + $('#new_records').removeClass('hidden') + + enable: -> + return if @enabled + + $('#bulk-panel').removeClass('hidden') + $('#new_record .btn').attr('value', 'Bulk Add') + $('#records .js-bulk-hide').hide() + + @enabled = true + + disable: -> + return if !@enabled + + $('#bulk-panel').addClass('hidden') + $('#inline-record-form .btn').attr('value', 'Add') + $('#records .js-bulk-hide').show() + @enabled = false + + commit: -> + data = { + deletes: id for id, _ of @deletes, + changes: rec for id, rec of @changes when !@deletes[id] , + additions: @additions + } + + _me = this + $.ajax { + url: @submit_url, + type: 'post', + data: JSON.stringify(data), + dataType: 'json', + contentType:'application/json', + success: (data) -> + console.log data + error: () -> + alert('There was an error submiting bulk operations') + } + + hook: -> + return if @hooked + + _me = this + # Hook bulky buttons + $('#js-bulky-activate').click -> + _me.enable() + $(this).parents('li').remove() + $('#js-bulky-cancel').click -> + _me.disable() + $('#js-bulky-commit').click -> + _me.commit() + $('#bulk-panel .js-modified-hover').hover \ + -> $('.modified').addClass('highlight') + , + -> $('.modified').removeClass('highlight') + + # Hook destroy button + $('#records, #new_records').on 'click', 'a.js-destroy', () -> + return true if !_me.enabled + + link = $(this) + id = link.data('id') + fresh = link.data('fresh') + + # Drop a newly created record + if fresh + delete _me.additions[id] + link.parents("#fresh-#{id}").remove() + # Resurrect a delete record + else if link.data('deleted') + delete _me.deletes[id] + link.data('deleted', false) + link.parents('tr').removeClass('danger') + link.parents('tr').attr('title', 'Remove') + link.find('span').removeClass('glyphicon-plus').addClass('glyphicon-remove') + # Delete a record + else + _me.deletes[id] = true + link.data('deleted', true) + link.parents('tr').addClass('danger') + link.find('abbr').attr('title', 'Undo') + link.find('span').removeClass('glyphicon-remove').addClass('glyphicon-plus') + + _me.panel_update() + return false; + + # Hook add button + $('#new_record').submit () -> + return true if !_me.enabled + + _me.validate_add $(this).serializeArray() + return false; + @hooked = true + + debug: -> + console.log("Bulky, enabled=#{@enabled}") + for id, rec of @additions + change = for k, v of rec + "#{k}: #{v}" + console.log("Add #{id}: #{change}") + for id, rec of @changes + change = for k, v of rec + "#{k}: #{v}" + console.log("Change #{id}: #{change}") + for id, _ of @deletes + console.log("Delete #{id}") + diff --git a/app/controllers/records_controller.rb b/app/controllers/records_controller.rb index 7bd8565..1a3e056 100644 --- a/app/controllers/records_controller.rb +++ b/app/controllers/records_controller.rb @@ -1,101 +1,128 @@ class RecordsController < ApplicationController before_action :authenticate_user! before_action :domain, except: [:search] before_action :editable_transform_params, only: [:editable] before_action :record, only: [:edit, :update, :editable, :destroy] # GET /records/new def new @record = domain.records.build end # GET /records/1/edit def edit end # POST /records def create @record = domain.records.new(new_record_params) if @record.save notify_record(@record, :create) redirect_to domain, notice: 'Record was successfully created.' else flash[:alert] = 'There were some errors creating the record!' render :new end end # PATCH/PUT /records/1 def update if @record.update(edit_record_params) notify_record(@record, :update) redirect_to domain, notice: 'Record was successfully updated.' else render :edit end end + def valid + @record = domain.records.new(new_record_params) + if @record.valid? + response = { + record: @record.as_bulky_json, + errors: false + } + + render json: response + else + render json: { errors: @record.errors.full_messages.join(', ') } + end + end + + def bulk + render json: { ok: true } + end + def editable - if @record.update(edit_record_params) - notify_record(@record, :update) + @record.assign_attributes(edit_record_params) + + if @record.valid? + if @save + @record.save! + notify_record(@record, :update) + end + response = { + attribute: @attr, value: @record.read_attribute(@attr), serial: @domain.soa(true).serial, - record: @record.as_json + record: @record.as_bulky_json, + saved: @save } render json: response else render text: @record.errors[@attr].join(', '), status: 400 end end # DELETE /records/1 def destroy @record.destroy notify_record(@record, :destroy) redirect_to domain, notice: 'Record was successfully destroyed.' end # GET /search def search @records = Record .where(domain: show_domain_scope) .includes(:domain) .search(params[:q]) # scope by domain @records = Record.smart_order(@records) end private # Modify params to use standard Rails patterns def editable_transform_params @attr = params[:name] + @save = params[:save] != 'false' params[:record] = { params[:name] => params[:value] } end def edit_record_params if @record.type == 'SOA' permitted = [:contact, :serial, :refresh, :retry, :expire, :nx] else permitted = [:name, :content, :ttl, :prio, :disabled] end params.require(:record).permit(*permitted).tap { |r| r[:drop_privileges] = true if not admin? } end def new_record_params params.require(:record).permit(:name, :content, :ttl, :type, :prio).tap { |r| r[:drop_privileges] = true if not admin? } end def notify_record(*args) notification.notify_record(current_user, *args) if WebDNS.settings[:notifications] end end diff --git a/app/models/record.rb b/app/models/record.rb index 82a4a7f..c29fa47 100644 --- a/app/models/record.rb +++ b/app/models/record.rb @@ -1,300 +1,311 @@ require 'ipaddr' require_dependency 'drop_privileges_validator' class Record < ActiveRecord::Base belongs_to :domain # Powerdns inserts empty records on slave zones, # we want to hide them # # http://mailman.powerdns.com/pipermail/pdns-users/2013-December/010389.html default_scope { where.not(type: nil) } # List all supported DNS RR types. def self.record_types [ 'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SPF', 'SRV', 'SSHFP', 'SOA', 'NS', 'PTR', 'NAPTR', 'DS' ] end # List types usually used in forward zones. def self.forward_records record_types - ['SOA', 'PTR'] end # List types usually used in reverse zones. def self.reverse_records ['PTR', 'CNAME', 'TXT', 'NS', 'NAPTR', 'DS'] end # List types usually used in enum zones. def self.enum_records ['NAPTR', 'CNAME', 'TXT', 'NS', 'DS'] end # List types that can be touched by a simple user. def self.allowed_record_types record_types - WebDNS.settings[:prohibit_records_types] end validates :name, presence: true validates :type, inclusion: { in: record_types } # http://mark.lindsey.name/2009/03/never-use-dns-ttl-of-zero-0.html validates_numericality_of :ttl, allow_nil: true, # Default pdns TTL only_integer: true, greater_than: 0, less_than_or_equal_to: 2_147_483_647 # Don't allow the following actions on drop privileges mode validate :no_touching_for_slave_zones, if: -> { domain.slave? } validates_drop_privileges :type, message: 'You cannot touch that record!', unless: -> { Record.allowed_record_types.include?(type) } validates_drop_privileges :name, message: 'You cannot touch top level NS records!', if: -> { type == 'NS' && domain_record? } before_validation :guess_reverse_name before_validation :set_name after_save :update_zone_serial after_destroy :update_zone_serial before_create :generate_classless_delegations, unless: -> { domain.slave? } before_destroy :delete_classless_delegations, unless: -> { domain.slave? } # Smart sort a list of records. # # Order by: # * Top level records # * Record name # * SOA # * NS # * Friendly type # * Priority # * Content # # records - The list of records to order. # # Returns the list sorted. def self.smart_order(records) records.sort_by { |r| [ r.domain_record? ? 0 : 1, # Zone records r.classless_delegated? ? 1 : 0, r.name, r.type == 'SOA' ? 0 : 1, r.type == 'NS' ? 0 : 1, record_types.index(r.type), # Friendly type r.prio, r.content ] } end def self.search(query) wild_search = "%#{query}%" # !index_friendly where('name like :q or content like :q', q: wild_search) end # Get the a short name for the record (without the zone suffix). # # Returns a string. def short return '' if name == domain.name return '' if name.blank? File.basename(name, ".#{domain.name}") end # Returns true if this is a zone record. def domain_record? name.blank? || name == domain.name end # Find out if the record is edittable. # # by - Editable by :user or :admin. # # Returns true if the record is editable. def editable?(by = :user) return false if domain.slave? return false if classless_delegated? case by when :user return false unless Record.allowed_record_types.include?(type) return false if type == 'NS' && domain_record? end true end # Find out this record type supports priorities. # # We set this to false by default, record types that support priorities. # shoule override this. # # Returns true this record type support priorities. def supports_prio? false end # Make sure rails generates record specific urls for all record types. # # Overrides default rails STI behavior. def self.model_name return super if self == Record Record.model_name end # Generate the usual admin friendly DNS record line. # # Returns a string. def to_dns [name, ttl, 'IN', type, supports_prio? ? prio : nil, content].compact.join(' ') end # Generate a shorter version of the DNS record line. # # Returns a string. def to_short_dns [name, 'IN', type].join(' ') end def classless_delegated? return false if not type == 'CNAME' return false if not domain.name.end_with?('.in-addr.arpa') network, mask = parse_delegation(content) return false if network.nil? octet = name.split('.').first.to_i return true if octet >= network return true if octet <= network + 2 ^ (32 - mask) - 1 # max false end def classless_delegation? return true if classless_delegation false end + def as_bulky_json + Hash[ + id: id, + name: name, + type: type, + ttl: ttl, + prio: prio, + content: content, + disabled: disabled + ] + end private # Validations def no_touching_for_slave_zones # Allow automatic SOA creation for slave zones # powerdns needs a valid serial to compare it with master return if type == 'SOA' && validation_context == :create errors.add(:type, 'This is a slave zone!') end # Hooks def guess_reverse_name return if not type == 'PTR' return if not domain.reverse? return if name.blank? reverse = IPAddr.new(name).reverse self.name = reverse if reverse.end_with?(domain.name) rescue IPAddr::InvalidAddressError # rubycop:disable HandleExceptions end # Powerdns expects full domain names def set_name self.name = domain.name if name.blank? self.name = "#{name}.#{domain.name}" if not name.end_with?(domain.name) end def remove_terminating_dot self.content = content.gsub(/\.+\Z/, '') end def update_zone_serial # SOA records handle serial themselves return true if type == 'SOA' return true if !domain domain.soa.bump_serial! end def classless_delegation return if not type == 'NS' return if not domain.name.end_with?('.in-addr.arpa') network, mask = parse_delegation(name) return if network.nil? range = IPAddr.new("0.0.0.#{network}/#{mask}").to_range return if !range.first.to_s.end_with?(".#{network}") range.map { |ip| octet = ip.to_s.split('.').last "#{octet}.#{domain.name}" } end def parse_delegation(value) first, _rest = value.split('.', 2) first.gsub!('-', '/') return if !first['/'] network, mask = first.split('/', 2).map { |i| Integer(i).abs } return if [network, mask].join('/') != first return if mask <= 24 return if mask > 31 return if network > 255 [network, mask] rescue ArgumentError # Not an integer end def delete_classless_delegations rnames = classless_delegation return unless rnames # Check if we have another NS for the same delegation return if domain.records.where(type: 'NS', name: name) .where.not(id: id).exists? # Delete all CNAMEs domain.records.where(name: rnames, type: 'CNAME', content: name).delete_all end def generate_classless_delegations rnames = classless_delegation return unless rnames # Make sure no record exists for a delegated domain if domain.records.where(name: rnames) .where.not(content: name).exists? errors.add(:name, 'Records already exist for the delegated octets!') return false end rnames.each { |rname| CNAME.find_or_create_by!( domain: domain, name: rname, content: name ) } end end diff --git a/app/views/domains/_bulk_new_records.html.erb b/app/views/domains/_bulk_new_records.html.erb new file mode 100644 index 0000000..54d05d5 --- /dev/null +++ b/app/views/domains/_bulk_new_records.html.erb @@ -0,0 +1,21 @@ +
diff --git a/app/views/domains/_bulk_panel.html.erb b/app/views/domains/_bulk_panel.html.erb new file mode 100644 index 0000000..a465234 --- /dev/null +++ b/app/views/domains/_bulk_panel.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/domains/show.html.erb b/app/views/domains/show.html.erb index e27f861..1f6b68d 100644 --- a/app/views/domains/show.html.erb +++ b/app/views/domains/show.html.erb @@ -1,52 +1,71 @@ + + <% content_for :more_breadcrumbs do %> -Records | <%= 'Controls' if !@domain.slave? %> | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
<%= editable_record_attr(record, :name) %> | <%= record.ttl %> | IN | <%= record.type %> | <% if record.supports_prio? %><%= editable_record_attr(record, :prio) %> | <% else %><% end %> | <%= editable_record_attr(record, :content) %> | <% if record.classless_delegation? %><%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> | <% elsif can_edit?(record) %><% if record.disabled? %> - <%= link_to_enable enable_domain_record_path(@domain, record), method: :put %> + <%= link_to_enable enable_domain_record_path(@domain, record), method: :put, class: 'js-bulk-hide' %> <% else %> - <%= link_to_disable disable_domain_record_path(@domain, record), method: :put %> + <%= link_to_disable disable_domain_record_path(@domain, record), method: :put, class: 'js-bulk-hide' %> <% end %> | -<%= link_to_edit edit_domain_record_path(@domain, record) %> | -<%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> | +<%= link_to_edit edit_domain_record_path(@domain, record), class: 'js-bulk-hide' %> | +<%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?', id: record.id }, class: 'js-destroy' %> | <% else %><% end %> |