Viewing File: /usr/local/cpanel/whostmgr/docroot/templates/hulkd/views/hulkdBlacklistController.js
/*
# cpanel - whostmgr/docroot/templates/hulkd/views/hulkdBlacklistController.js
# Copyright 2022 cPanel, L.L.C.
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/
/* eslint camelcase: 0, no-prototype-builtins: 0 */
define(
[
"angular",
"jquery",
"lodash",
"cjt/util/locale",
"app/utils/download",
"uiBootstrap",
"cjt/directives/toggleSortDirective",
"cjt/decorators/paginationDecorator",
"cjt/decorators/growlDecorator",
"cjt/filters/startFromFilter",
"app/services/HulkdDataSource",
],
function(angular, $, _, LOCALE, Download) {
"use strict";
// Retrieve the current application
var app = angular.module("App");
app.config([ "$compileProvider",
function($compileProvider) {
$compileProvider.aHrefSanitizationWhitelist(/^blob:https:/);
},
]);
var controller = app.controller(
"hulkdBlacklistController",
["$scope", "$filter", "$routeParams", "$uibModal", "HulkdDataSource", "growl", "PAGE", "$timeout",
function($scope, $filter, $routeParams, $uibModal, HulkdDataSource, growl, PAGE, $timeout) {
$scope.blacklist_reverse = false;
$scope.blacklist = [];
$scope.blacklist_comments = {};
$scope.adding_batch_to_blacklist = false;
$scope.new_blacklist_records = "";
$scope.ip_being_edited = false;
$scope.current_ip = null;
$scope.current_comment = "";
$scope.updating_comment = false;
$scope.modal_instance = null;
$scope.loading = false;
$scope.downloadAllLink = "";
$scope.downloadSelectionLink = "";
$scope.meta = {
sortDirection: "asc",
sortBy: "black_ip",
sortType: "",
sortReverse: false,
filter: "",
maxPages: 0,
totalItems: $scope.blacklist.length || 0,
currentPage: 1,
pageNumberStart: 0,
pageNumberEnd: 0,
pageSize: 20,
pageSizes: [20, 50, 100],
};
$scope.LOCALE = LOCALE;
var filters = {
filter: $filter("filter"),
orderBy: $filter("orderBy"),
startFrom: $filter("startFrom"),
limitTo: $filter("limitTo"),
};
$scope.delete_in_progress = false;
$scope.ips_to_delete = [];
$scope.selecting_page_size = false;
$scope.edit_blacklist_ip = function(blacklist_ip) {
$scope.current_ip = blacklist_ip;
$scope.current_comment = $scope.blacklist_comments.hasOwnProperty(blacklist_ip) ? $scope.blacklist_comments[blacklist_ip] : "";
$scope.ip_being_edited = true;
var blacklist_comment_field = $("#blacklist_current_comment");
var wait_id = setInterval( function() {
if (blacklist_comment_field.is(":visible")) {
blacklist_comment_field.focus();
blacklist_comment_field.select();
clearInterval(wait_id);
}
}, 250);
};
$scope.cancel_blacklist_editing = function() {
$scope.current_ip = null;
$scope.current_comment = "";
$scope.ip_being_edited = false;
$scope.focus_on_blacklist();
};
$scope.delete_tooltip = function(ip_address) {
return LOCALE.maketext("Click to delete “[_1]” from the blacklist.", ip_address);
};
$scope.edit_tooltip = function(ip_address) {
return LOCALE.maketext("Click to edit the comment for “[_1]”.", ip_address);
};
$scope.update_blacklist_comment = function() {
if ($scope.updating_comment) {
return;
}
$scope.updating_comment = true;
HulkdDataSource.add_to_blacklist([ { ip: $scope.current_ip, comment: $scope.current_comment } ])
.then( function(results) {
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
// Growl out each success from the batch.
results.updated.forEach(function(ip) {
growl.success(LOCALE.maketext("You have successfully updated the comment for “[_1]”.", _.escape(ip)));
});
// Report the failures from the batch.
var rejectedMessages = [];
Object.keys(results.rejected).forEach(function(ip) {
rejectedMessages.push(_.escape(ip) + ": " + _.escape(results.rejected[ip]));
});
if (rejectedMessages.length > 0) {
var accumulatedMessages = LOCALE.maketext("Some records failed to update.") + "<br>";
accumulatedMessages += rejectedMessages.join("<br>");
growl.error(accumulatedMessages);
}
}, function(error) {
growl.error(error);
})
.finally(function() {
$scope.updating_comment = false;
$scope.cancel_blacklist_editing();
$scope.focus_on_blacklist();
});
};
var ipV6 = /^(([\da-fA-F]{1,4}:){4})(([\da-fA-F]{1,4}:){3})([\da-fA-F]{1,4})$/;
var ipV4Range = /^((\d{1,3}.){3}\d{1,3})-((\d{1,3}.){3}\d{1,3})$/;
var ipRangeTest = /-/;
var ipV6Test = /:/;
/**
* Separates long ipv4 and ipv6 addresses with br tags.
* Also, supports separating ipv4 and ipv6 address ranges.
*
* @param {string} ip - an ip address
* @todo Implement this as an Angular Filter in a separate file
*/
$scope.splitLongIp = function(ip) {
// ipv6?
if (ipV6Test.test(ip)) {
// is this a range?
if (ipRangeTest.test(ip)) {
// format the ipv6 addresses in range format
var ipv6Addresses = ip.split(ipRangeTest);
var ipv6AddressRange = "";
// get the first part of the range
var match = ipV6.exec(ipv6Addresses[0]);
if (match) {
ipv6AddressRange += match[1] + "<br>" + match[3] + match[5];
}
// add the range separator
ipv6AddressRange += "-<br>";
// get the second part of the range
match = ipV6.exec(ipv6Addresses[1]);
if (match) {
ipv6AddressRange += match[1] + "<br>" + match[3] + match[5];
}
// if all we have is -<br>, then forget it
if (ipv6AddressRange.length > 5) {
return ipv6AddressRange;
}
} else {
// format the ipv6 address
var v6match = ipV6.exec(ip);
if (v6match) {
return v6match[1] + "<br>" + v6match[3] + v6match[5];
}
}
} else {
// format the ipv4 range
var v4rangeMatch = ipV4Range.exec(ip);
if (v4rangeMatch) {
return v4rangeMatch[1] + "-<br>" + v4rangeMatch[3];
}
}
// could not format it, just return it
return ip;
};
$scope.$watch(function() {
return HulkdDataSource.enabled;
}, function() {
$scope.load_list();
});
$scope.$watchGroup([ "blacklist.length", "meta.filteredList.length" ], function() {
if ($scope.blacklist.length === 0 || $scope.meta.filteredList.length === 0) {
$("#blacklist_select_all_checkbox").prop("checked", false);
}
});
$scope.selectPage = function(page) {
$("#blacklist_select_all_checkbox").prop("checked", false);
// set the page if requested
if (page && angular.isNumber(page)) {
$scope.meta.currentPage = page;
}
$scope.load_list();
};
$scope.selectPageSize = function() {
return $scope.load_list({ reset_focus: false });
};
/**
* Filter the list by the `meta.filter`.
*/
$scope.filterList = function() {
$scope.meta.currentPage = 1;
$scope.load_list({ reset_focus: false });
};
/**
* Clear the filter if it is set.
*/
$scope.toggleFilter = function() {
$scope.meta.filter = "";
$scope.load_list({ reset_focus: false });
};
$scope.sortList = function(meta) {
$scope.meta.sortReverse = (meta.sortDirection === "asc") ? false : true;
$scope.applyFilters();
};
$scope.orderByComments = function(comment_object, ip_list) {
var comments_as_pairs = _.toPairs(comment_object);
var ips_as_pairs = [];
// get the IPs that have no comments
for (var i = 0; i < ip_list.length; i++) {
if (!_.has(comment_object, ip_list[i] )) {
var one_entry = [ip_list[i], "" ];
ips_as_pairs.push(one_entry);
}
}
// sort the IPs that have no comments
var sorted_pairs = _.sortBy(ips_as_pairs, function(pair) {
return $scope.ip_padder(pair[0]);
});
// sort the comments first by comment, then by IP address
comments_as_pairs.sort(compareComments);
// create an array of the IPs from the sorted comments
var just_ips_comments = _.map(comments_as_pairs, function(pair) {
return pair[0];
});
// create an array of the sorted IPs with no comments
var just_ips = _.map(sorted_pairs, function(pair) {
return pair[0];
});
// put the IPs with comments and the IPs without comments together
var stuck_together = just_ips_comments.concat(just_ips);
if ($scope.meta.sortDirection === "desc") {
return stuck_together.reverse();
}
return stuck_together;
};
/**
* Apply the sort, filter and pagination to the blacklist data.
*
* @returns {string[]} List of ips that pass the filters.
*/
$scope.applyFilters = function() {
var filteredList = [];
var start, limit;
filteredList = $scope.blacklist;
// Sort
if ($scope.meta.sortDirection !== "" && $scope.meta.sortBy !== "") {
if ($scope.meta.sortBy === "black_ip") {
filteredList = filters.orderBy(filteredList, $scope.ip_padder, $scope.meta.sortReverse);
} else {
filteredList = $scope.orderByComments($scope.blacklist_comments, $scope.blacklist);
}
}
// Totals
$scope.meta.totalItems = $scope.blacklist.length;
// Filter content
var expected = $scope.meta.filter.toLowerCase();
if (expected) {
filteredList = filters.filter(filteredList, function(actual) {
return actual.indexOf(expected) !== -1 ||
($scope.blacklist_comments[actual] && $scope.blacklist_comments[actual].toLowerCase().indexOf(expected) !== -1);
});
}
// Track the filtered size separatly
$scope.meta.filteredItems = filteredList.length;
// Pagination
start = ($scope.meta.currentPage - 1) * $scope.meta.pageSize;
limit = $scope.meta.pageSize;
filteredList = filters.limitTo(filters.startFrom(filteredList, start), limit);
$scope.meta.pageNumberStart = start + 1;
$scope.meta.pageNumberEnd = ($scope.meta.currentPage * $scope.meta.pageSize);
if ($scope.meta.totalItems === 0) {
$scope.meta.pageNumberStart = 0;
}
if ($scope.meta.pageNumberEnd > $scope.meta.totalItems) {
$scope.meta.pageNumberEnd = $scope.meta.totalItems;
}
$scope.meta.filteredList = filteredList;
return filteredList;
};
$scope.load_list = function(options) {
if (HulkdDataSource.enabled && !$scope.loading) {
$scope.loading = true;
var reset_focus = typeof options !== "undefined" && options.hasOwnProperty("reset_focus") ? options.reset_focus : true;
if (HulkdDataSource.blacklist_is_cached) {
$scope.blacklist = HulkdDataSource.blacklist;
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
$scope.downloadAllLink = $scope.generateDownloadAllLink();
$scope.applyFilters();
if (reset_focus) {
$scope.focus_on_blacklist();
}
$scope.loading = false;
} else {
$scope.meta.filteredList = [];
return HulkdDataSource.load_list("black")
.then(function() {
$scope.blacklist = HulkdDataSource.blacklist;
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
$scope.downloadAllLink = $scope.generateDownloadAllLink();
$scope.applyFilters();
}, function(error) {
growl.error(error);
})
.finally(function() {
if (reset_focus) {
$scope.focus_on_blacklist();
}
$scope.selecting_page_size = false;
$scope.loading = false;
});
}
}
return null;
};
$scope.force_load_blacklist = function() {
HulkdDataSource.blacklist_is_cached = false;
$scope.blacklist = [];
$scope.blacklist_comments = {};
$scope.meta.filteredList = [];
return $scope.load_list();
};
$scope.delete_confirmation_message = function() {
if ($scope.ips_to_delete.length === 1) {
return LOCALE.maketext("Do you want to permanently delete “[_1]” from the blacklist?", $scope.ips_to_delete[0]);
} else {
return LOCALE.maketext("Do you want to permanently delete [quant,_1,record,records] from the backlist?", $scope.ips_to_delete.length);
}
};
$scope.itemsAreChecked = function() {
return $(".blacklist_select_item").filter(":checked").length > 0;
};
$scope.check_blacklist_selection = function() {
if ($(".blacklist_select_item").filter(":not(:checked)").length === 0) {
$("#blacklist_select_all_checkbox").prop("checked", true);
} else {
$("#blacklist_select_all_checkbox").prop("checked", false);
}
$scope.downloadSelectionLink = $scope.generateDownloadSelectionLink();
};
/**
* Get the list of ips selected in the UI.
*
* @returns <string[]> List if ips selected.
*/
$scope.getSelection = function() {
var selected_items = [],
$selected_dom_nodes = $(".blacklist_select_item:checked");
if ($selected_dom_nodes.length === 0) {
return [];
}
$selected_dom_nodes.each( function() {
selected_items.push($(this).data("ip"));
});
return selected_items;
};
$scope.confirm_blacklist_deletion = function(ip_to_delete) {
if ($scope.blacklist.length === 0) {
return false;
}
$scope.delete_in_progress = true;
if (ip_to_delete !== undefined) {
$scope.ips_to_delete = [ip_to_delete];
$scope.is_single_deletion = true;
} else {
var selected_items = $scope.getSelection();
if (selected_items.length === 0) {
return false;
}
$scope.ips_to_delete = selected_items;
$scope.is_single_deletion = false;
}
$scope.modal_instance = $uibModal.open({
templateUrl: "confirm_blacklist_deletion.html",
scope: $scope,
});
return true;
};
$scope.clear_modal_instance = function() {
if ($scope.modal_instance) {
$scope.modal_instance.close();
$scope.modal_instance = null;
}
};
$scope.cancel_deletion = function() {
$scope.delete_in_progress = false;
$scope.ips_to_delete = [];
$scope.clear_modal_instance();
$scope.focus_on_blacklist();
};
$scope.delete_blacklist_ips = function(is_single_deletion) {
$scope.clear_modal_instance();
HulkdDataSource.remove_from_blacklist($scope.ips_to_delete)
.then( function(results) {
$scope.blacklist = HulkdDataSource.blacklist;
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
$scope.applyFilters();
$scope.focus_on_blacklist();
if (results.removed.length === 1) {
growl.success(LOCALE.maketext("You have successfully deleted “[_1]” from the blacklist.", _.escape(results.removed[0])));
} else {
growl.success(LOCALE.maketext("You have successfully deleted [quant,_1,record,records] from the blacklist.", results.removed.length));
}
if (results.not_removed.keys && results.not_removed.keys.length > 0) {
growl.warning(LOCALE.maketext("The system was unable to delete [quant,_1,record,records] from the blacklist.", results.not_removed.keys.length));
}
}, function(error) {
growl.error(error);
})
.finally( function() {
$scope.delete_in_progress = false;
$scope.ips_to_delete = [];
if (!is_single_deletion) {
$scope.deselect_all_blacklist();
}
// Since this is using JQuery/DOM, we have to wait another tick for the UI to update
// before we try to get the selection.
$timeout(function() {
$scope.downloadAllLink = $scope.generateDownloadAllLink();
$scope.downloadSelectionLink = $scope.generateDownloadSelectionLink();
});
});
};
$scope.confirm_delete_all = function() {
if ($scope.blacklist.length === 0) {
return false;
}
$scope.delete_in_progress = true;
$scope.modal_instance = $uibModal.open({
templateUrl: "confirm_blacklist_delete_all.html",
scope: $scope,
});
return true;
};
$scope.cancel_delete_all = function() {
$scope.delete_in_progress = false;
$scope.clear_modal_instance();
$scope.focus_on_blacklist();
};
$scope.delete_all = function() {
$scope.clear_modal_instance();
HulkdDataSource.remove_all_from_blacklist()
.then( function(results) {
$scope.blacklist = HulkdDataSource.blacklist;
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
$scope.applyFilters();
$scope.focus_on_blacklist();
if (results.not_removed.keys && results.not_removed.keys.length > 0) {
growl.success(LOCALE.maketext("You have successfully deleted [quant,_1,record,records] from the blacklist.", results.removed.keys.length));
growl.warning(LOCALE.maketext("The system was unable to delete [quant,_1,record,records] from the blacklist.", results.not_removed.keys.length));
} else {
growl.success(LOCALE.maketext("You have deleted all records from the blacklist."));
}
}, function(error) {
growl.error(error);
})
.finally( function() {
$scope.delete_in_progress = false;
$scope.deselect_all_blacklist();
// Since this is using JQuery/DOM, we have to wait another tick for the UI to update
// before we try to get the selection.
$timeout(function() {
$scope.downloadAllLink = $scope.generateDownloadAllLink();
$scope.downloadSelectionLink = $scope.generateDownloadSelectionLink();
});
});
};
$scope.select_all_blacklist = function() {
if ($scope.blacklist.length === 0) {
return false;
}
$(".blacklist_select_item").prop("checked", true);
$("#blacklist_select_all_checkbox").prop("checked", true);
$scope.downloadSelectionLink = $scope.generateDownloadSelectionLink();
return true;
};
$scope.deselect_all_blacklist = function() {
if ($scope.blacklist.length === 0) {
return false;
}
$(".blacklist_select_item").prop("checked", false);
$("#blacklist_select_all_checkbox").prop("checked", false);
$scope.downloadSelectionLink = $scope.generateDownloadSelectionLink();
return true;
};
$scope.toggle_blacklist_selection = function() {
if ($("#blacklist_select_all_checkbox").prop("checked") === true) {
$scope.select_all_blacklist();
} else {
$scope.deselect_all_blacklist();
}
};
$scope.focus_on_blacklist = function() {
var blacklist_batch_field = $("#blacklist_batch_add");
var wait_id = setInterval( function() {
if (blacklist_batch_field.is(":visible")) {
blacklist_batch_field.focus();
blacklist_batch_field.select();
clearInterval(wait_id);
}
}, 250);
};
/**
*
* @typedef Record
* @property {string} ip
* @property {string?} comment
*/
/**
* Parse the batch of records.
*
* @param {string} text
* @returns {Record[]}
*/
function parseBatch(text) {
var lines = text.split("\n");
var records = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line && line.length > 0) {
var parts = line.split("#");
var ip = parts.shift().trim();
var comment = parts.join("#").trim();
records.push({
ip: ip,
comment: comment,
});
}
}
return records;
}
/**
* Add a batch of records to the blacklist.
*
* @async
* @returns
*/
$scope.add_to_blacklist = function() {
if (!$scope.new_blacklist_records || $scope.adding_batch_to_blacklist) {
return;
}
var records = parseBatch($scope.new_blacklist_records);
return $scope._add_to_blacklist(records);
};
/**
* Add a batch of blacklist records.
*
* @private
* @param {Record[]} batch
* @returns
*/
$scope._add_to_blacklist = function(batch) {
$scope.adding_batch_to_blacklist = true;
HulkdDataSource.add_to_blacklist(batch)
.then( function(results) {
$scope.blacklist = HulkdDataSource.blacklist;
$scope.blacklist_comments = HulkdDataSource.blacklist_comments;
$scope.downloadAllLink = $scope.generateDownloadAllLink();
$scope.applyFilters();
if (results.added.length === 1) {
growl.success(LOCALE.maketext("You have successfully added “[_1]” to the blacklist.", _.escape(results.added[0])));
} else if (results.added.length > 1) {
growl.success(LOCALE.maketext("You have successfully added [quant,_1,IP address,IP addresses] to the blacklist.", results.added.length));
}
if (results.updated.length === 1) {
growl.success(LOCALE.maketext("You have successfully updated the comment for “[_1]”.", _.escape(results.updated[0])));
} else if (results.updated.length > 1) {
growl.success(LOCALE.maketext("You have successfully updated the [numerate,_1,comment,comments] for [quant,_1,IP address,IP addresses].", results.updated.length));
}
var rejectedIps = Object.keys(results.rejected);
if (rejectedIps.length > 0) {
var accumulatedMessages = LOCALE.maketext("Some IP addresses were not added to the blacklist.");
accumulatedMessages += "<br>\n";
// Put the rejected ips/comments back in the list.
$scope.new_blacklist_records = rejectedIps.map(function(ip) {
var record = batch.find(function(record) {
return record.ip === ip;
});
if (record && record.comment) {
return ip + " # " + record.comment + "\n";
}
return ip + "\n";
}).join("");
// Report the problems in the growl
accumulatedMessages += "<ul>\n";
rejectedIps.forEach(function(ip) {
if (results.rejected[ip]) {
accumulatedMessages += "<li>" + _.escape(results.rejected[ip]) + "</li>\n";
}
});
accumulatedMessages += "</ul>\n";
growl.error(accumulatedMessages);
} else {
$scope.new_blacklist_records = "";
}
}, function(error_details) {
var error = error_details.main_message;
// Format the individual partial errors.
var secondary_count = error_details.secondary_messages.length;
if (secondary_count > 0) {
error += "<ul>\n";
}
error_details.secondary_messages.forEach(function(message) {
error += "<li>" + _.escape(message) + "</li>\n";
});
if (secondary_count > 0) {
error += "</ul>\n";
}
growl.error(error);
})
.finally(function() {
$scope.adding_batch_to_blacklist = false;
$scope.focus_on_blacklist();
});
};
// Handle auto-adding an ip from a query param or POST
if (($routeParams["ip"] && $routeParams["ip"].length > 0) ||
PAGE.ipToAdd !== null) {
var ip;
var comment = "";
if ($routeParams["ip"] && $routeParams["ip"].length > 0) {
// added via a query param
ip = $routeParams["ip"];
} else if (PAGE.ipToAdd !== null) {
// added via a POST and stuffed into PAGE
ip = PAGE.ipToAdd;
}
// clear the ip so we don't add it again
PAGE.ipToAdd = null;
if (ip !== void 0) {
$scope._add_to_blacklist([ { ip: ip, comment: comment } ]);
}
}
// TODO: Make this a utility system: ip-comparison
$scope.ip_padder = function(unpadded) {
var padded_ip = "";
if (unpadded) {
var split_ip = unpadded.split(".");
for (var i = 0; i < split_ip.length; i++) {
var this_section = split_ip[i];
while ( this_section.length < 3) {
this_section = "0" + this_section;
}
padded_ip += this_section;
}
}
return padded_ip;
};
function compareComments(a, b) {
// sort by comment
if (a[1].toLowerCase() < b[1].toLowerCase()) {
return -1;
}
if (a[1].toLowerCase() > b[1].toLowerCase()) {
return 1;
}
// we have a duplicate comment, so sort by IP address
if ($scope.ip_padder(a[0]) < $scope.ip_padder(b[0])) {
return -1;
}
if ($scope.ip_padder(a[0]) > $scope.ip_padder(b[0])) {
return 1;
}
// we have a duplicate comment and IP
return 0;
}
/**
* Generate the download name.
*
* @returns {string} - the name of the download.
*/
$scope.downloadName = function() {
return Download.getDownloadName("blacklist");
};
/**
* @typedef IpRecord
* @property {string} ip - ip address or range.
* @property {string?} comment - comment associated with the ip or range.
*/
/**
* Package the ips and comments into a records structure
*
* @param {string[]} ips
* @param {Dictionary<string,string>} comments
* @returns {IpRecord[]}
*/
function getRecords(ips, comments) {
var list = [];
ips.forEach(function(ip) {
var comment = comments[ip];
list.push({ ip: ip, comment: comment });
});
return list;
}
/**
* Generate a data blob url that contains all the blacklist ips.
*
* @returns {string} - data url.
*/
$scope.generateDownloadAllLink = function() {
if ($scope.downloadAllLink) {
// Clean up the previous url
Download.cleanupDownloadUrl($scope.downloadAllLink);
$scope.downloadAllLink = null;
}
var ips = $scope.blacklist;
if (!ips || ips.length === 0) {
return "";
}
var list = getRecords(ips, $scope.blacklist_comments);
return Download.getTextDownloadUrl(Download.formatList(list));
};
/**
* Generate a data blob url that contains the selected blacklist ips.
*
* @returns {string} - data url.
*/
$scope.generateDownloadSelectionLink = function() {
if ($scope.downloadSelectionLink) {
// Clean up the previous url
Download.cleanupDownloadUrl($scope.downloadSelectionLink);
$scope.downloadSelectionLink = null;
}
if (!$scope.blacklist || $scope.blacklist.length === 0) {
return "";
}
var selection = $scope.getSelection();
if (!selection || selection.length === 0) {
return "";
}
var list = getRecords(selection, $scope.blacklist_comments);
return Download.getTextDownloadUrl(Download.formatList(list));
};
$scope.focus_on_blacklist();
},
]);
return controller;
}
);
Back to Directory
File Manager