diff --git a/groups-adlists.php b/groups-adlists.php index 5a4688e5..c5aacdd9 100644 --- a/groups-adlists.php +++ b/groups-adlists.php @@ -63,6 +63,7 @@ ID + Address Status Comment diff --git a/groups-clients.php b/groups-clients.php index 2257b0de..c48c8585 100644 --- a/groups-clients.php +++ b/groups-clients.php @@ -70,6 +70,7 @@ ID + Client Comment Group assignment diff --git a/groups-domains.php b/groups-domains.php index 3877c89a..a2040307 100644 --- a/groups-domains.php +++ b/groups-domains.php @@ -121,6 +121,7 @@ ID + Domain/RegEx Type Status diff --git a/groups.php b/groups.php index 22e741e7..7c70549f 100644 --- a/groups.php +++ b/groups.php @@ -61,6 +61,7 @@ ID + Name Status Description diff --git a/scripts/pi-hole/js/groups-adlists.js b/scripts/pi-hole/js/groups-adlists.js index 1ff87a0d..f41f9310 100644 --- a/scripts/pi-hole/js/groups-adlists.js +++ b/scripts/pi-hole/js/groups-adlists.js @@ -117,8 +117,9 @@ function initTable() { order: [[0, "asc"]], columns: [ { data: "id", visible: false }, - { data: "status", searchable: false, class: "details-control" }, - { data: "address" }, + { data: null, visible: true, orderable: false, width: "15px" }, + { data: "status", searchable: false, orderable: false, class: "details-control" }, + { data: "address", orderable: false }, { data: "enabled", searchable: false }, { data: "comment" }, { data: "groups", searchable: false }, @@ -126,8 +127,11 @@ function initTable() { ], columnDefs: [ { - targets: [0, 2], - orderable: false, + targets: 1, + className: "select-checkbox", + render: function () { + return ""; + }, }, { targets: "_all", @@ -135,6 +139,10 @@ function initTable() { }, ], drawCallback: function () { + // Hide buttons if all messages were deleted + var hasRows = this.api().rows({ filter: "applied" }).data().length > 0; + $(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden"); + $('button[id^="deleteAdlist_"]').on("click", deleteAdlist); // Remove visible dropdown to prevent orphaning $("body > .bootstrap-select.dropdown").remove(); @@ -176,19 +184,19 @@ function initTable() { extra = ""; } - $("td:eq(0)", row).addClass("list-status-" + statusCode); - $("td:eq(0)", row).html( + $("td:eq(1)", row).addClass("list-status-" + statusCode); + $("td:eq(1)", row).html( "" + extra ); if (data.address.startsWith("file://")) { // Local files cannot be downloaded from a distant client so don't show // a link to such a list here - $("td:eq(1)", row).html( + $("td:eq(2)", row).html( '' + data.address + "" ); } else { - $("td:eq(1)", row).html( + $("td:eq(2)", row).html( '" ); var statusEl = $("#status_" + data.id, row); @@ -212,13 +220,13 @@ function initTable() { }); statusEl.on("change", editAdlist); - $("td:eq(3)", row).html(''); + $("td:eq(4)", row).html(''); var commentEl = $("#comment_" + data.id, row); commentEl.val(utils.unescapeHtml(data.comment)); commentEl.on("change", editAdlist); - $("td:eq(4)", row).empty(); - $("td:eq(4)", row).append( + $("td:eq(5)", row).empty(); + $("td:eq(5)", row).append( '' ); var selectEl = $("#multiselect_" + data.id, row); @@ -285,20 +293,67 @@ function initTable() { var button = '"; - $("td:eq(5)", row).html(button); + $("td:eq(6)", row).html(button); }, dom: - "<'row'<'col-sm-12'f>>" + - "<'row'<'col-sm-4'l><'col-sm-8'p>>" + + "<'row'<'col-sm-6'l><'col-sm-6'f>>" + + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'<'table-responsive'tr>>>" + - "<'row'<'col-sm-5'i><'col-sm-7'p>>", + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + + "<'row'<'col-sm-12'i>>", lengthMenu: [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"], ], + select: { + style: "multi", + selector: "td:not(:last-child)", + info: false, + }, + buttons: [ + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectAll", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectMore", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + extend: "selectNone", + text: '', + titleAttr: "Deselect All", + className: "btn-sm datatable-bt removeAll", + }, + { + text: '', + titleAttr: "Delete Selected", + className: "btn-sm datatable-bt deleteSelected", + action: function () { + // For each ".selected" row ... + var ids = []; + $("tr.selected").each(function () { + // ... add the row identified by "data-id". + ids.push(parseInt($(this).attr("data-id"), 10)); + }); + // Delete all selected rows at once + delItems(ids); + }, + }, + ], stateSave: true, stateDuration: 0, stateSaveCallback: function (settings, data) { @@ -319,6 +374,10 @@ function initTable() { }, }); + table.on("init select deselect", function () { + utils.changeBulkDeleteStates(table); + }); + table.on("order.dt", function () { var order = table.order(); if (order[0][0] !== 0 || order[0][1] !== "asc") { @@ -327,6 +386,7 @@ function initTable() { $("#resetButton").addClass("hidden"); } }); + $("#resetButton").on("click", function () { table.order([[0, "asc"]]).draw(); $("#resetButton").addClass("hidden"); @@ -358,6 +418,66 @@ function initTable() { } } +// Remove 'bnt-group' class from container, to avoid grouping +$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons"; + +function deleteAdlist() { + // Passes the button data-del-id attribute as ID + var ids = [parseInt($(this).attr("data-del-id"), 10)]; + delItems(ids); +} + +function delItems(ids) { + // Check input validity + if (!Array.isArray(ids)) return; + + var address = ""; + + // Exploit prevention: Return early for non-numeric IDs + for (var id of ids) { + if (typeof id !== "number") return; + address += "
  • " + utils.escapeHtml($("#address_" + id).text()) + "
  • "; + } + + utils.disableAll(); + var idstring = ids.join(", "); + utils.showAlert("info", "", "Deleting Adlists: " + idstring, "..."); + + $.ajax({ + url: "scripts/pi-hole/php/groups.php", + method: "post", + dataType: "json", + data: { action: "delete_adlist", id: JSON.stringify(ids), token: token }, + }) + .done(function (response) { + utils.enableAll(); + if (response.success) { + utils.showAlert( + "success", + "far fa-trash-alt", + "Successfully deleted adlists: " + idstring, + "" + ); + for (var id in ids) { + if (Object.hasOwnProperty.call(ids, id)) { + table.row(id).remove().draw(false).ajax.reload(null, false); + } + } + } else { + utils.showAlert("error", "", "Error while deleting adlists: " + idstring, response.message); + } + + // Clear selection after deletion + table.rows().deselect(); + utils.changeBulkDeleteStates(table); + }) + .fail(function (jqXHR, exception) { + utils.enableAll(); + utils.showAlert("error", "", "Error while deleting adlists: " + idstring, jqXHR.responseText); + console.log(exception); // eslint-disable-line no-console + }); +} + function addAdlist() { var address = utils.escapeHtml($("#new_address").val()); var comment = utils.escapeHtml($("#new_comment").val()); @@ -490,32 +610,3 @@ function editAdlist() { }, }); } - -function deleteAdlist() { - var tr = $(this).closest("tr"); - var id = tr.attr("data-id"); - var address = utils.escapeHtml(tr.find("#address_" + id).text()); - - utils.disableAll(); - utils.showAlert("info", "", "Deleting adlist...", address); - $.ajax({ - url: "scripts/pi-hole/php/groups.php", - method: "post", - dataType: "json", - data: { action: "delete_adlist", id: id, token: token }, - success: function (response) { - utils.enableAll(); - if (response.success) { - utils.showAlert("success", "far fa-trash-alt", "Successfully deleted adlist ", address); - table.row(tr).remove().draw(false).ajax.reload(null, false); - } else { - utils.showAlert("error", "", "Error while deleting adlist with ID " + id, response.message); - } - }, - error: function (jqXHR, exception) { - utils.enableAll(); - utils.showAlert("error", "", "Error while deleting adlist with ID " + id, jqXHR.responseText); - console.log(exception); // eslint-disable-line no-console - }, - }); -} diff --git a/scripts/pi-hole/js/groups-clients.js b/scripts/pi-hole/js/groups-clients.js index a2907426..444e55c3 100644 --- a/scripts/pi-hole/js/groups-clients.js +++ b/scripts/pi-hole/js/groups-clients.js @@ -92,18 +92,31 @@ function initTable() { order: [[0, "asc"]], columns: [ { data: "id", visible: false }, + { data: null, visible: true, width: "15px" }, { data: "ip", type: "ip-address" }, { data: "comment" }, { data: "groups", searchable: false }, { data: "name", width: "22px", orderable: false }, ], columnDefs: [ + { + targets: 1, + orderable: false, + className: "select-checkbox", + render: function () { + return ""; + }, + }, { targets: "_all", render: $.fn.dataTable.render.text(), }, ], drawCallback: function () { + // Hide buttons if all messages were deleted + var hasRows = this.api().rows({ filter: "applied" }).data().length > 0; + $(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden"); + $('button[id^="deleteClient_"]').on("click", deleteClient); // Remove visible dropdown to prevent orphaning $("body > .bootstrap-select.dropdown").remove(); @@ -134,15 +147,15 @@ function initTable() { '" class="breakall">' + data.name + ""; - $("td:eq(0)", row).html(ipName); + $("td:eq(1)", row).html(ipName); - $("td:eq(1)", row).html(''); + $("td:eq(2)", row).html(''); var commentEl = $("#comment_" + data.id, row); commentEl.val(utils.unescapeHtml(data.comment)); commentEl.on("change", editClient); - $("td:eq(2)", row).empty(); - $("td:eq(2)", row).append( + $("td:eq(3)", row).empty(); + $("td:eq(3)", row).append( '' ); var selectEl = $("#multiselect_" + data.id, row); @@ -209,16 +222,63 @@ function initTable() { var button = '"; - $("td:eq(3)", row).html(button); + $("td:eq(4)", row).html(button); }, + select: { + style: "multi", + selector: "td:not(:last-child)", + info: false, + }, + buttons: [ + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectAll", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectMore", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + extend: "selectNone", + text: '', + titleAttr: "Deselect All", + className: "btn-sm datatable-bt removeAll", + }, + { + text: '', + titleAttr: "Delete Selected", + className: "btn-sm datatable-bt deleteSelected", + action: function () { + // For each ".selected" row ... + var ids = []; + $("tr.selected").each(function () { + // ... add the row identified by "data-id". + ids.push(parseInt($(this).attr("data-id"), 10)); + }); + // Delete all selected rows at once + delItems(ids); + }, + }, + ], dom: - "<'row'<'col-sm-12'f>>" + - "<'row'<'col-sm-4'l><'col-sm-8'p>>" + + "<'row'<'col-sm-6'l><'col-sm-6'f>>" + + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'<'table-responsive'tr>>>" + - "<'row'<'col-sm-5'i><'col-sm-7'p>>", + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + + "<'row'<'col-sm-12'i>>", lengthMenu: [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"], @@ -252,6 +312,10 @@ function initTable() { input.setAttribute("spellcheck", false); } + table.on("init select deselect", function () { + utils.changeBulkDeleteStates(table); + }); + table.on("order.dt", function () { var order = table.order(); if (order[0][0] !== 0 || order[0][1] !== "asc") { @@ -267,6 +331,75 @@ function initTable() { }); } +// Remove 'bnt-group' class from container, to avoid grouping +$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons"; + +function deleteClient() { + // Passes the button data-del-id attribute as ID + var ids = [parseInt($(this).attr("data-del-id"), 10)]; + delItems(ids); +} + +function delItems(ids) { + // Check input validity + if (!Array.isArray(ids)) return; + + var items = ""; + var name = ""; + + for (var id of ids) { + // Exploit prevention: Return early for non-numeric IDs + if (typeof id !== "number") return; + + // Retrieve details + name = utils.escapeHtml($("#name_" + id).text()); + if (name.length > 0) { + name = " (" + utils.escapeHtml($("#name_" + id).text()) + ")"; + } + + // Add client + items += "
  • " + utils.escapeHtml($("#ip_" + id).text()) + name + "
  • "; + } + + utils.disableAll(); + var idstring = ids.join(", "); + utils.showAlert("info", "", "Deleting clients: " + idstring, "..."); + + $.ajax({ + url: "scripts/pi-hole/php/groups.php", + method: "post", + dataType: "json", + data: { action: "delete_client", id: JSON.stringify(ids), token: token }, + }) + .done(function (response) { + utils.enableAll(); + if (response.success) { + utils.showAlert( + "success", + "far fa-trash-alt", + "Successfully deleted clients: " + idstring, + "" + ); + for (var id in ids) { + if (Object.hasOwnProperty.call(ids, id)) { + table.row(id).remove().draw(false).ajax.reload(null, false); + } + } + } else { + utils.showAlert("error", "", "Error while deleting clients: " + idstring, response.message); + } + + // Clear selection after deletion + table.rows().deselect(); + utils.changeBulkDeleteStates(table); + }) + .fail(function (jqXHR, exception) { + utils.enableAll(); + utils.showAlert("error", "", "Error while deleting clients: " + idstring, jqXHR.responseText); + console.log(exception); // eslint-disable-line no-console + }); +} + function addClient() { var ip = utils.escapeHtml($("#select").val().trim()); var comment = utils.escapeHtml($("#new_comment").val()); @@ -389,38 +522,3 @@ function editClient() { }, }); } - -function deleteClient() { - var tr = $(this).closest("tr"); - var id = tr.attr("data-id"); - var ip = utils.escapeHtml(tr.find("#ip_" + id).text()); - var name = utils.escapeHtml(tr.find("#name_" + id).text()); - - if (name.length > 0) { - ip += " (" + name + ")"; - } - - utils.disableAll(); - utils.showAlert("info", "", "Deleting client...", ip); - $.ajax({ - url: "scripts/pi-hole/php/groups.php", - method: "post", - dataType: "json", - data: { action: "delete_client", id: id, token: token }, - success: function (response) { - utils.enableAll(); - if (response.success) { - utils.showAlert("success", "far fa-trash-alt", "Successfully deleted client ", ip); - table.row(tr).remove().draw(false).ajax.reload(null, false); - reloadClientSuggestions(); - } else { - utils.showAlert("error", "", "Error while deleting client with ID " + id, response.message); - } - }, - error: function (jqXHR, exception) { - utils.enableAll(); - utils.showAlert("error", "", "Error while deleting client with ID " + id, jqXHR.responseText); - console.log(exception); // eslint-disable-line no-console - }, - }); -} diff --git a/scripts/pi-hole/js/groups-domains.js b/scripts/pi-hole/js/groups-domains.js index 2faa9a83..6a9440cf 100644 --- a/scripts/pi-hole/js/groups-domains.js +++ b/scripts/pi-hole/js/groups-domains.js @@ -69,6 +69,7 @@ function initTable() { order: [[0, "asc"]], columns: [ { data: "id", visible: false }, + { data: null, visible: true, orderable: false, width: "15px" }, { data: "domain" }, { data: "type", searchable: false }, { data: "enabled", searchable: false }, @@ -77,12 +78,24 @@ function initTable() { { data: null, width: "22px", orderable: false }, ], columnDefs: [ + { + targets: 1, + orderable: false, + className: "select-checkbox", + render: function () { + return ""; + }, + }, { targets: "_all", render: $.fn.dataTable.render.text(), }, ], drawCallback: function () { + // Hide buttons if all messages were deleted + var hasRows = this.api().rows({ filter: "applied" }).data().length > 0; + $(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden"); + $('button[id^="deleteDomain_"]').on("click", deleteDomain); // Remove visible dropdown to prevent orphaning $("body > .bootstrap-select.dropdown").remove(); @@ -96,7 +109,7 @@ function initTable() { utils.datetime(data.date_modified, false) + "\nDatabase ID: " + data.id; - $("td:eq(0)", row).html( + $("td:eq(1)", row).html( 'Regex blacklist"; } - $("td:eq(1)", row).html( + $("td:eq(2)", row).html( '" ); var statusEl = $("#status_" + data.id, row); @@ -153,15 +166,15 @@ function initTable() { }); statusEl.on("change", editDomain); - $("td:eq(3)", row).html(''); + $("td:eq(4)", row).html(''); var commentEl = $("#comment_" + data.id, row); commentEl.val(utils.unescapeHtml(data.comment)); commentEl.on("change", editDomain); // Show group assignment field only if in full domain management mode - if (table.column(5).visible()) { - $("td:eq(4)", row).empty(); - $("td:eq(4)", row).append( + if (table.column(6).visible()) { + $("td:eq(5)", row).empty(); + $("td:eq(5)", row).append( '' ); var selectEl = $("#multiselect_" + data.id, row); @@ -234,20 +247,67 @@ function initTable() { var button = '"; - if (table.column(5).visible()) { - $("td:eq(5)", row).html(button); + if (table.column(6).visible()) { + $("td:eq(6)", row).html(button); } else { - $("td:eq(4)", row).html(button); + $("td:eq(5)", row).html(button); } }, + select: { + style: "multi", + selector: "td:not(:last-child)", + info: false, + }, + buttons: [ + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectAll", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectMore", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + extend: "selectNone", + text: '', + titleAttr: "Deselect All", + className: "btn-sm datatable-bt removeAll", + }, + { + text: '', + titleAttr: "Delete Selected", + className: "btn-sm datatable-bt deleteSelected", + action: function () { + // For each ".selected" row ... + var ids = []; + $("tr.selected").each(function () { + // ... add the row identified by "data-id". + ids.push(parseInt($(this).attr("data-id"), 10)); + }); + // Delete all selected rows at once + delItems(ids); + }, + }, + ], dom: - "<'row'<'col-sm-12'f>>" + - "<'row'<'col-sm-4'l><'col-sm-8'p>>" + + "<'row'<'col-sm-6'l><'col-sm-6'f>>" + + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'<'table-responsive'tr>>>" + - "<'row'<'col-sm-5'i><'col-sm-7'p>>", + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + + "<'row'<'col-sm-12'i>>", lengthMenu: [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"], @@ -268,7 +328,7 @@ function initTable() { // Reset visibility of ID column data.columns[0].visible = false; // Show group assignment column only on full page - data.columns[5].visible = showtype === "all"; + data.columns[6].visible = showtype === "all"; // Apply loaded state to table return data; }, @@ -294,6 +354,10 @@ function initTable() { input.setAttribute("spellcheck", false); } + table.on("init select deselect", function () { + utils.changeBulkDeleteStates(table); + }); + table.on("order.dt", function () { var order = table.order(); if (order[0][0] !== 0 || order[0][1] !== "asc") { @@ -302,12 +366,85 @@ function initTable() { $("#resetButton").addClass("hidden"); } }); + $("#resetButton").on("click", function () { table.order([[0, "asc"]]).draw(); $("#resetButton").addClass("hidden"); }); } +// Remove 'bnt-group' class from container, to avoid grouping +$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons"; + +function deleteDomain() { + // Passes the button data-del-id attribute as ID + var ids = [parseInt($(this).attr("data-del-id"), 10)]; + delItems(ids); +} + +function delItems(ids) { + // Check input validity + if (!Array.isArray(ids)) return; + + var items = ""; + var type = ""; + var typeID = ""; + + for (var id of ids) { + // Exploit prevention: Return early for non-numeric IDs + if (typeof id !== "number") return; + + // Retrieve domain type + typeID = $("#type_" + id).val(); + if (typeID === "0" || typeID === "1") { + type = " (domain)"; + } else if (typeID === "2" || typeID === "3") { + type = " (regex)"; + } + + // Add item + items += "
  • " + utils.escapeHtml($("#domain_" + id).text()) + type + "
  • "; + } + + utils.disableAll(); + var idstring = ids.join(", "); + utils.showAlert("info", "", "Deleting items: " + idstring, "..."); + + $.ajax({ + url: "scripts/pi-hole/php/groups.php", + method: "post", + dataType: "json", + data: { action: "delete_domain", id: JSON.stringify(ids), token: token }, + }) + .done(function (response) { + utils.enableAll(); + if (response.success) { + utils.showAlert( + "success", + "far fa-trash-alt", + "Successfully deleted items: " + idstring, + "" + ); + for (var id in ids) { + if (Object.hasOwnProperty.call(ids, id)) { + table.row(id).remove().draw(false).ajax.reload(null, false); + } + } + } else { + utils.showAlert("error", "", "Error while deleting items: " + idstring, response.message); + } + + // Clear selection after deletion + table.rows().deselect(); + utils.changeBulkDeleteStates(table); + }) + .fail(function (jqXHR, exception) { + utils.enableAll(); + utils.showAlert("error", "", "Error while deleting items: " + idstring, jqXHR.responseText); + console.log(exception); // eslint-disable-line no-console + }); +} + function addDomain() { var action = this.id; var tabHref = $('a[data-toggle="tab"][aria-expanded="true"]').attr("href"); @@ -490,55 +627,3 @@ function editDomain() { }, }); } - -function deleteDomain() { - var tr = $(this).closest("tr"); - var id = tr.attr("data-id"); - var domain = utils.escapeHtml(tr.find("#domain_" + id).text()); - var type = tr.find("#type_" + id).val(); - - var domainRegex; - if (type === "0" || type === "1") { - domainRegex = "domain"; - } else if (type === "2" || type === "3") { - domainRegex = "regex"; - } - - utils.disableAll(); - utils.showAlert("info", "", "Deleting " + domainRegex + "...", domain); - $.ajax({ - url: "scripts/pi-hole/php/groups.php", - method: "post", - dataType: "json", - data: { action: "delete_domain", id: id, token: token }, - success: function (response) { - utils.enableAll(); - if (response.success) { - utils.showAlert( - "success", - "far fa-trash-alt", - "Successfully deleted " + domainRegex, - domain - ); - table.row(tr).remove().draw(false).ajax.reload(null, false); - } else { - utils.showAlert( - "error", - "", - "Error while deleting " + domainRegex + " with ID " + id, - response.message - ); - } - }, - error: function (jqXHR, exception) { - utils.enableAll(); - utils.showAlert( - "error", - "", - "Error while deleting " + domainRegex + " with ID " + id, - jqXHR.responseText - ); - console.log(exception); // eslint-disable-line no-console - }, - }); -} diff --git a/scripts/pi-hole/js/groups.js b/scripts/pi-hole/js/groups.js index 8b7e3b63..d9c811f3 100644 --- a/scripts/pi-hole/js/groups.js +++ b/scripts/pi-hole/js/groups.js @@ -22,18 +22,31 @@ $(function () { order: [[0, "asc"]], columns: [ { data: "id", visible: false }, + { data: null, visible: true, width: "15px" }, { data: "name" }, { data: "enabled", searchable: false }, { data: "description" }, { data: null, width: "22px", orderable: false }, ], columnDefs: [ + { + targets: 1, + orderable: false, + className: "select-checkbox", + render: function () { + return ""; + }, + }, { targets: "_all", render: $.fn.dataTable.render.text(), }, ], drawCallback: function () { + // Hide buttons if all messages were deleted + var hasRows = this.api().rows({ filter: "applied" }).data().length > 0; + $(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden"); + $('button[id^="deleteGroup_"]').on("click", deleteGroup); }, rowCallback: function (row, data) { @@ -45,7 +58,7 @@ $(function () { utils.datetime(data.date_modified, false) + "\nDatabase ID: " + data.id; - $("td:eq(0)", row).html( + $("td:eq(1)", row).html( '' ); var nameEl = $("#name_" + data.id, row); @@ -53,7 +66,7 @@ $(function () { nameEl.on("change", editGroup); var disabled = data.enabled === 0; - $("td:eq(1)", row).html( + $("td:eq(2)", row).html( '" ); var statusEl = $("#status_" + data.id, row); @@ -66,28 +79,75 @@ $(function () { }); statusEl.on("change", editGroup); - $("td:eq(2)", row).html(''); + $("td:eq(3)", row).html(''); var desc = data.description !== null ? data.description : ""; var descEl = $("#desc_" + data.id, row); descEl.val(utils.unescapeHtml(desc)); descEl.on("change", editGroup); - $("td:eq(3)", row).empty(); + $("td:eq(4)", row).empty(); if (data.id !== 0) { var button = '"; - $("td:eq(3)", row).html(button); + $("td:eq(4)", row).html(button); } }, + select: { + style: "multi", + selector: "td:not(:last-child)", + info: false, + }, + buttons: [ + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectAll", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + text: '', + titleAttr: "Select All", + className: "btn-sm datatable-bt selectMore", + action: function () { + table.rows({ page: "current" }).select(); + }, + }, + { + extend: "selectNone", + text: '', + titleAttr: "Deselect All", + className: "btn-sm datatable-bt removeAll", + }, + { + text: '', + titleAttr: "Delete Selected", + className: "btn-sm datatable-bt deleteSelected", + action: function () { + // For each ".selected" row ... + var ids = []; + $("tr.selected").each(function () { + // ... add the row identified by "data-id". + ids.push(parseInt($(this).attr("data-id"), 10)); + }); + // Delete all selected rows at once + delItems(ids); + }, + }, + ], dom: - "<'row'<'col-sm-12'f>>" + - "<'row'<'col-sm-4'l><'col-sm-8'p>>" + + "<'row'<'col-sm-6'l><'col-sm-6'f>>" + + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'<'table-responsive'tr>>>" + - "<'row'<'col-sm-5'i><'col-sm-7'p>>", + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + + "<'row'<'col-sm-12'i>>", lengthMenu: [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"], @@ -106,7 +166,7 @@ $(function () { } // Reset visibility of ID column - data.columns[0].visible = false; + data.columns[1].visible = false; // Apply loaded state to table return data; }, @@ -121,6 +181,10 @@ $(function () { input.setAttribute("spellcheck", false); } + table.on("init select deselect", function () { + utils.changeBulkDeleteStates(table); + }); + table.on("order.dt", function () { var order = table.order(); if (order[0][0] !== 0 || order[0][1] !== "asc") { @@ -129,12 +193,82 @@ $(function () { $("#resetButton").addClass("hidden"); } }); + $("#resetButton").on("click", function () { table.order([[0, "asc"]]).draw(); $("#resetButton").addClass("hidden"); }); }); +// Remove 'bnt-group' class from container, to avoid grouping +$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons"; + +function deleteGroup() { + // Passes the button data-del-id attribute as ID + var ids = [parseInt($(this).attr("data-del-id"), 10)]; + delItems(ids); +} + +function delItems(ids) { + // Check input validity + if (!Array.isArray(ids)) return; + + var items = ""; + var name = ""; + + for (var id of ids) { + // Exploit prevention: Return early for non-numeric IDs + if (typeof id !== "number") return; + + // Retrieve details + name = utils.escapeHtml($("#name_" + id).text()); + if (name.length > 0) { + name = " (" + utils.escapeHtml($("#name_" + id).text()) + ")"; + } + + // Add item + items += "
  • " + utils.escapeHtml($("#ip_" + id).text()) + name + "
  • "; + } + + utils.disableAll(); + var idstring = ids.join(", "); + utils.showAlert("info", "", "Deleting groups: " + idstring, "..."); + + $.ajax({ + url: "scripts/pi-hole/php/groups.php", + method: "post", + dataType: "json", + data: { action: "delete_group", id: JSON.stringify(ids), token: token }, + }) + .done(function (response) { + utils.enableAll(); + if (response.success) { + utils.showAlert( + "success", + "far fa-trash-alt", + "Successfully deleted groups: " + idstring, + "" + ); + for (var id in ids) { + if (Object.hasOwnProperty.call(ids, id)) { + table.row(id).remove().draw(false).ajax.reload(null, false); + } + } + } else { + utils.showAlert("error", "", "Error while deleting groups: " + idstring, response.message); + } + + // Clear selection after deletion + table.rows().deselect(); + utils.changeBulkDeleteStates(table); + }) + .fail(function (jqXHR, exception) { + utils.enableAll(); + utils.showAlert("error", "", "Error while deleting groups: " + idstring, jqXHR.responseText); + console.log(exception); // eslint-disable-line no-console + }); +} + function addGroup() { var name = utils.escapeHtml($("#new_name").val()); var desc = utils.escapeHtml($("#new_desc").val()); @@ -246,32 +380,3 @@ function editGroup() { }, }); } - -function deleteGroup() { - var tr = $(this).closest("tr"); - var id = tr.attr("data-id"); - var name = utils.escapeHtml(tr.find("#name_" + id).val()); - - utils.disableAll(); - utils.showAlert("info", "", "Deleting group...", name); - $.ajax({ - url: "scripts/pi-hole/php/groups.php", - method: "post", - dataType: "json", - data: { action: "delete_group", id: id, token: token }, - success: function (response) { - utils.enableAll(); - if (response.success) { - utils.showAlert("success", "far fa-trash-alt", "Successfully deleted group ", name); - table.row(tr).remove().draw(false); - } else { - utils.showAlert("error", "", "Error while deleting group with ID " + id, response.message); - } - }, - error: function (jqXHR, exception) { - utils.enableAll(); - utils.showAlert("error", "", "Error while deleting group with ID " + id, jqXHR.responseText); - console.log(exception); // eslint-disable-line no-console - }, - }); -} diff --git a/scripts/pi-hole/js/utils.js b/scripts/pi-hole/js/utils.js index f6d2434a..16aa4473 100644 --- a/scripts/pi-hole/js/utils.js +++ b/scripts/pi-hole/js/utils.js @@ -381,6 +381,33 @@ function checkMessages() { }); } +// Show only the appropriate delete buttons in datatables +function changeBulkDeleteStates(table) { + var allRows = table.rows({ filter: "applied" }).data().length; + var pageLength = table.page.len(); + var selectedRows = table.rows(".selected").data().length; + + if (selectedRows === 0) { + // Nothing selected + $(".selectAll").removeClass("hidden"); + $(".selectMore").addClass("hidden"); + $(".removeAll").addClass("hidden"); + $(".deleteSelected").addClass("hidden"); + } else if (selectedRows >= pageLength || selectedRows === allRows) { + // Whole page is selected (or all available messages were selected) + $(".selectAll").addClass("hidden"); + $(".selectMore").addClass("hidden"); + $(".removeAll").removeClass("hidden"); + $(".deleteSelected").removeClass("hidden"); + } else { + // Some rows are selected, but not all + $(".selectAll").addClass("hidden"); + $(".selectMore").removeClass("hidden"); + $(".removeAll").addClass("hidden"); + $(".deleteSelected").removeClass("hidden"); + } +} + window.utils = (function () { return { escapeHtml: escapeHtml, @@ -404,5 +431,6 @@ window.utils = (function () { addTD: addTD, colorBar: colorBar, checkMessages: checkMessages, + changeBulkDeleteStates: changeBulkDeleteStates, }; })(); diff --git a/scripts/pi-hole/php/groups.php b/scripts/pi-hole/php/groups.php index b57258f7..967c0772 100644 --- a/scripts/pi-hole/php/groups.php +++ b/scripts/pi-hole/php/groups.php @@ -153,21 +153,26 @@ if ($_POST['action'] == 'get_groups') { } elseif ($_POST['action'] == 'delete_group') { // Delete group identified by ID try { + $ids = json_decode($_POST['id']); + // Exploit prevention: Ensure all entries in the ID array are integers + foreach($ids as $value) { + if (!is_numeric($value)) { + throw new Exception('Invalid payload: id'); + } + } + $table_name = ['domainlist_by_group', 'client_by_group', 'adlist_by_group', 'group']; $table_keys = ['group_id', 'group_id', 'group_id', 'id']; + for ($i = 0; $i < count($table_name); $i++) { $table = $table_name[$i]; $key = $table_keys[$i]; - $stmt = $db->prepare("DELETE FROM \"$table\" WHERE $key = :id;"); + $stmt = $db->prepare("DELETE FROM ".$table." WHERE ".$key." IN ('.implode(",",$ids).')'"); if (!$stmt) { throw new Exception("While preparing DELETE FROM $table statement: " . $db->lastErrorMsg()); } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception("While binding id to DELETE FROM $table statement: " . $db->lastErrorMsg()); - } - if (!$stmt->execute()) { throw new Exception("While executing DELETE FROM $table statement: " . $db->lastErrorMsg()); } @@ -463,30 +468,32 @@ if ($_POST['action'] == 'get_groups') { } elseif ($_POST['action'] == 'delete_client') { // Delete client identified by ID try { - $db->query('BEGIN TRANSACTION;'); - - $stmt = $db->prepare('DELETE FROM client_by_group WHERE client_id=:id'); - if (!$stmt) { - throw new Exception('While preparing client_by_group statement: ' . $db->lastErrorMsg()); + $ids = json_decode($_POST['id']); + // Exploit prevention: Ensure all entries in the ID array are integers + foreach($ids as $value) { + if (!is_numeric($value)) { + throw new Exception('Invalid payload: id'); + } } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to client_by_group statement: ' . $db->lastErrorMsg()); + $db->query('BEGIN TRANSACTION;'); + + // Delete from: client_by_group + $stmt = $db->prepare('DELETE FROM client_by_group WHERE client_id IN ('.implode(",",$ids).')'); + if (!$stmt) { + throw new Exception('While preparing client_by_group statement: ' . $db->lastErrorMsg()); } if (!$stmt->execute()) { throw new Exception('While executing client_by_group statement: ' . $db->lastErrorMsg()); } - $stmt = $db->prepare('DELETE FROM client WHERE id=:id'); + // Delete from: client + $stmt = $db->prepare('DELETE FROM client WHERE id IN ('.implode(",",$ids).')'); if (!$stmt) { throw new Exception('While preparing client statement: ' . $db->lastErrorMsg()); } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to client statement: ' . $db->lastErrorMsg()); - } - if (!$stmt->execute()) { throw new Exception('While executing client statement: ' . $db->lastErrorMsg()); } @@ -847,30 +854,32 @@ if ($_POST['action'] == 'get_groups') { } elseif ($_POST['action'] == 'delete_domain') { // Delete domain identified by ID try { - $db->query('BEGIN TRANSACTION;'); - - $stmt = $db->prepare('DELETE FROM domainlist_by_group WHERE domainlist_id=:id'); - if (!$stmt) { - throw new Exception('While preparing domainlist_by_group statement: ' . $db->lastErrorMsg()); + $ids = json_decode($_POST['id']); + // Exploit prevention: Ensure all entries in the ID array are integers + foreach($ids as $value) { + if (!is_numeric($value)) { + throw new Exception('Invalid payload: id'); + } } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to domainlist_by_group statement: ' . $db->lastErrorMsg()); + $db->query('BEGIN TRANSACTION;'); + + // Delete from: domainlist_by_group + $stmt = $db->prepare('DELETE FROM domainlist_by_group WHERE domainlist_id IN ('.implode(",",$ids).')'); + if (!$stmt) { + throw new Exception('While preparing domainlist_by_group statement: ' . $db->lastErrorMsg()); } if (!$stmt->execute()) { throw new Exception('While executing domainlist_by_group statement: ' . $db->lastErrorMsg()); } - $stmt = $db->prepare('DELETE FROM domainlist WHERE id=:id'); + // Delete from: domainlist + $stmt = $db->prepare('DELETE FROM domainlist WHERE id IN ('.implode(",",$ids).')'); if (!$stmt) { throw new Exception('While preparing domainlist statement: ' . $db->lastErrorMsg()); } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to domainlist statement: ' . $db->lastErrorMsg()); - } - if (!$stmt->execute()) { throw new Exception('While executing domainlist statement: ' . $db->lastErrorMsg()); } @@ -1128,30 +1137,38 @@ if ($_POST['action'] == 'get_groups') { } elseif ($_POST['action'] == 'delete_adlist') { // Delete adlist identified by ID try { - $db->query('BEGIN TRANSACTION;'); - - $stmt = $db->prepare('DELETE FROM adlist_by_group WHERE adlist_id=:id'); - if (!$stmt) { - throw new Exception('While preparing adlist_by_group statement: ' . $db->lastErrorMsg()); + // Accept only an array + $ids = json_decode($_POST['id']); + if (!is_array($ids)) { + throw new Exception('Invalid payload: id is not an array'); } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to adlist_by_group statement: ' . $db->lastErrorMsg()); + // Exploit prevention: Ensure all entries in the ID array are integers + foreach ($ids as $value) { + if (!is_numeric($value)) { + throw new Exception('Invalid payload: id contains non-numeric entries'); + } + } + + $db->query('BEGIN TRANSACTION;'); + + // Delete from: adlists_by_group + $stmt = $db->prepare('DELETE FROM adlist_by_group WHERE adlist_id IN ('.implode(",",$ids).')'); + + if (!$stmt) { + throw new Exception('While preparing adlist_by_group statement: ' . $db->lastErrorMsg()); } if (!$stmt->execute()) { throw new Exception('While executing adlist_by_group statement: ' . $db->lastErrorMsg()); } - $stmt = $db->prepare('DELETE FROM adlist WHERE id=:id'); + // Delete from: adlists + $stmt = $db->prepare('DELETE FROM adlist WHERE id IN ('.implode(",",$ids).')'); if (!$stmt) { throw new Exception('While preparing adlist statement: ' . $db->lastErrorMsg()); } - if (!$stmt->bindValue(':id', intval($_POST['id']), SQLITE3_INTEGER)) { - throw new Exception('While binding id to adlist statement: ' . $db->lastErrorMsg()); - } - if (!$stmt->execute()) { throw new Exception('While executing adlist statement: ' . $db->lastErrorMsg()); } diff --git a/style/pi-hole.css b/style/pi-hole.css index 85960018..5d1a3570 100644 --- a/style/pi-hole.css +++ b/style/pi-hole.css @@ -620,12 +620,12 @@ li:not(.menu-open) .treeview-menu .warning-count { } #domainsTable tr { display: flex; - padding: 15px 0; + padding: 5px 0; flex-wrap: wrap; box-sizing: border-box; align-items: end; border: 1px solid rgba(127, 127, 127, 0.4); - margin: 10px 0; + margin: 15px 0; border-radius: 6px; } #domainsTable td { @@ -634,14 +634,23 @@ li:not(.menu-open) .treeview-menu .warning-count { display: block; border: none; box-sizing: border-box; - order: 2; + order: 3; text-align: right; padding: 4px 8px; } - #domainsTable td:nth-child(2n) { + #domainsTable td:nth-child(2n + 1) { width: calc(100% - 120px); } - #domainsTable td:first-child { + #domainsTable td:nth-child(1) { + width: 40px; + order: 0; + text-align: left; + padding: 5px 0 30px; + border-bottom: 1px solid rgba(127, 127, 127, 0.25); + margin-bottom: 5px; + flex: 0 0 auto; + } + #domainsTable td:nth-child(2) { width: calc(100% - 40px); order: 1; text-align: left; @@ -651,13 +660,13 @@ li:not(.menu-open) .treeview-menu .warning-count { } #domainsTable td:last-child { width: 40px; - order: 1; + order: 0; padding-bottom: 10px; border-bottom: 1px solid rgba(127, 127, 127, 0.25); margin-bottom: 5px; } - #domainsTable td:nth-child(2), - #domainsTable td:nth-child(4) { + #domainsTable td:nth-child(3), + #domainsTable td:nth-child(5) { text-align: left; } #domainsTable td::before { @@ -665,12 +674,16 @@ li:not(.menu-open) .treeview-menu .warning-count { font-weight: bold; font-size: smaller; } - #domainsTable td:nth-child(2)::before { + #domainsTable td:nth-child(3)::before { content: "Type:"; } - #domainsTable td:nth-child(4)::before { + #domainsTable td:nth-child(5)::before { content: "Comment:"; } + #domainsTable tr.selected td.select-checkbox::after, + #domainsTable tr.selected th.select-checkbox::after { + transform: rotate(45deg) translate(5px, 1px); + } } @media screen and (min-width: 767px) { #domainsTable select.form-control {