diff options
Diffstat (limited to 'interface/js')
-rw-r--r-- | interface/js/app/common.js | 130 | ||||
-rw-r--r-- | interface/js/app/rspamd.js | 4 | ||||
-rw-r--r-- | interface/js/app/selectors.js | 10 | ||||
-rw-r--r-- | interface/js/app/symbols.js | 1 | ||||
-rw-r--r-- | interface/js/app/upload.js | 261 |
5 files changed, 310 insertions, 96 deletions
diff --git a/interface/js/app/common.js b/interface/js/app/common.js index ace4bbba1..6f37d739e 100644 --- a/interface/js/app/common.js +++ b/interface/js/app/common.js @@ -57,6 +57,20 @@ define(["jquery", "nprogress"], }, 5000); } + /** + * Perform a request to a single Rspamd neighbour server. + * + * @param {Array.<Object>} neighbours_status + * Array of neighbour status objects. + * @param {number} ind + * Index of this neighbour in the `neighbours_status` array. + * @param {string} req_url + * Relative controller endpoint with optional query string. + * @param {Object} o + * The same `options` object passed into `ui.query`. + * + * @returns {void} + */ function queryServer(neighbours_status, ind, req_url, o) { neighbours_status[ind].checked = false; neighbours_status[ind].data = {}; @@ -152,23 +166,51 @@ define(["jquery", "nprogress"], }; /** - * @param {string} url - A string containing the URL to which the request is sent - * @param {Object} [options] - A set of key/value pairs that configure the Ajax request. All settings are optional. + * Perform an HTTP request to one or all Rspamd neighbours. * - * @param {Function} [options.complete] - A function to be called when the requests to all neighbours complete. - * @param {Object|string|Array} [options.data] - Data to be sent to the server. - * @param {Function} [options.error] - A function to be called if the request fails. - * @param {string} [options.errorMessage] - Text to display in the alert message if the request fails. - * @param {string} [options.errorOnceId] - A prefix of the alert ID to be added to the session storage. If the - * parameter is set, the error for each server will be displayed only once per session. - * @param {Object} [options.headers] - An object of additional header key/value pairs to send along with requests - * using the XMLHttpRequest transport. - * @param {string} [options.method] - The HTTP method to use for the request. - * @param {Object} [options.params] - An object of additional jQuery.ajax() settings key/value pairs. - * @param {string} [options.server] - A server to which send the request. - * @param {Function} [options.success] - A function to be called if the request succeeds. + * @param {string} url + * Relative URL, including with optional query string (e.g. "plugins/selectors/check_selector?selector=from"). + * @param {Object} [options] + * Ajax request configuration options. + * @param {Object|string|Array} [options.data] + * Request body for POST endpoints. + * @param {Object} [options.headers] + * Additional HTTP headers. + * @param {"GET"|"POST"} [options.method] + * HTTP method (defaults to "GET"). + * @param {string} [options.server] + * Name or base-URL of the target server (defaults to the currently selected Rspamd neighbour). + * @param {Object} [options.params] + * Extra jQuery.ajax() settings (e.g. timeout, dataType). + * @param {string} [options.errorMessage] + * Text to show inside a Bootstrap alert on generic errors (e.g. network failure). + * @param {string} [options.errorOnceId] + * Prefix for an alert ID stored in session storage to ensure + * `errorMessage` is shown only once per server each session. + * @param {function(Array.<Object>, Object)} [options.success] + * Called on HTTP success. Receives: + * 1. results: Array of per-server status objects: + * { + * name: string, + * host: string, + * url: string, // full URL base for this neighbour + * checked: boolean, // whether this server was attempted + * status: boolean, // HTTP success (<400) + * data: any, // parsed JSON or raw text + * percentComplete: number + * } + * 2. jqXHR: jQuery XHR object with properties + * { readyState, status, statusText, responseText, responseJSON, … } + * @param {function(Object, Object, string, string)} [options.error] + * Called on HTTP error or network failure. Receives: + * 1. result: a per-server status object (status:false, data:{}). + * 2. jqXHR: jQuery XHR object (responseText, responseJSON, status, statusText). + * 3. textStatus: string describing error type ("error", "timeout", etc.). + * 4. errorThrown: exception message or HTTP statusText. + * @param {function()} [options.complete] + * Called once all servers have been tried; takes no arguments. * - * @returns {undefined} + * @returns {void} */ ui.query = function (url, options) { // Force options to be an object @@ -261,5 +303,63 @@ define(["jquery", "nprogress"], ).appendTo(ftFilter.$dropdown); }; + ui.fileUtils = { + readFile(files, callback, index = 0) { + const file = files[index]; + const reader = new FileReader(); + reader.onerror = () => alertMessage("alert-error", `Error reading file: ${file.name}`); + reader.onloadend = () => callback(reader.result); + reader.readAsText(file); + }, + + setFileInputFiles(fileInput, files, i) { + const dt = new DataTransfer(); + if (arguments.length > 2) dt.items.add(files[i]); + $(fileInput).prop("files", dt.files); + }, + + setupFileHandling(textArea, fileInput, fileSet, enable_btn_cb, multiple_files_cb) { + const dragoverClassList = "outline-dashed-primary bg-primary-subtle"; + const {readFile, setFileInputFiles} = ui.fileUtils; + + function handleFileInput(fileSource) { + fileSet.files = fileSource.files; + fileSet.index = 0; + const {files} = fileSet; + + if (files.length === 1) { + setFileInputFiles(fileInput, files, 0); + enable_btn_cb(); + readFile(files, (result) => { + $(textArea).val(result); + enable_btn_cb(); + }); + } else if (multiple_files_cb) { + multiple_files_cb(files); + } else { + alertMessage("alert-warning", "Multiple files processing is not supported."); + } + } + + $(textArea) + .on("dragenter dragover dragleave drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + }) + .on("dragenter dragover", () => $(textArea).addClass(dragoverClassList)) + .on("dragleave drop", () => $(textArea).removeClass(dragoverClassList)) + .on("drop", (e) => handleFileInput(e.originalEvent.dataTransfer)) + .on("input", () => { + enable_btn_cb(); + if (fileSet.files) { + fileSet.files = null; + setFileInputFiles(fileInput, fileSet.files); + } + }); + + $(fileInput).on("change", (e) => handleFileInput(e.target)); + } + }; + return ui; }); diff --git a/interface/js/app/rspamd.js b/interface/js/app/rspamd.js index 6d047d6f6..ceba6864b 100644 --- a/interface/js/app/rspamd.js +++ b/interface/js/app/rspamd.js @@ -176,7 +176,7 @@ define(["jquery", "app/common", "stickytabs", "visibility", require(["app/symbols"], (module) => module.getSymbols()); break; case "#scan_nav": - require(["app/upload"]); + require(["app/upload"], (module) => module.getFuzzyStorages()); break; case "#selectors_nav": require(["app/selectors"], (module) => module.displayUI()); @@ -236,6 +236,8 @@ define(["jquery", "app/common", "stickytabs", "visibility", complete: function () { ajaxSetup(localStorage.getItem("ajax_timeout")); + if (require.defined("app/upload")) require(["app/upload"], (module) => module.getClassifiers()); + if (common.read_only) { $(".ro-disable").attr("disabled", true); $(".ro-hide").hide(); diff --git a/interface/js/app/selectors.js b/interface/js/app/selectors.js index c2b8b27e5..4a1c6d0d0 100644 --- a/interface/js/app/selectors.js +++ b/interface/js/app/selectors.js @@ -2,6 +2,7 @@ define(["jquery", "app/common"], ($, common) => { "use strict"; const ui = {}; + const fileSet = {files: null, index: null}; function enable_disable_check_btn() { $("#selectorsChkMsgBtn").prop("disabled", ( @@ -129,12 +130,15 @@ define(["jquery", "app/common"], return false; }); - $("#selectorsMsgArea").on("input", () => { - enable_disable_check_btn(); - }); $("#selectorsSelArea").on("input", () => { checkSelectors(); }); + $("#selectorsMsgClean").on("click", () => { + $("#selectorsMsgArea").val(""); + $("#selectorsFile").val(""); + }); + + common.fileUtils.setupFileHandling("#selectorsMsgArea", "#selectorsFile", fileSet, enable_disable_check_btn); return ui; }); diff --git a/interface/js/app/symbols.js b/interface/js/app/symbols.js index 3ff5d5a4b..21711a1e5 100644 --- a/interface/js/app/symbols.js +++ b/interface/js/app/symbols.js @@ -135,6 +135,7 @@ define(["jquery", "app/common", "footable"], construct: function (instance) { this._super(instance); [,this.groups] = items; + this.groups.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); this.def = "Any group"; this.$group = null; }, diff --git a/interface/js/app/upload.js b/interface/js/app/upload.js index d85196296..c82bb2306 100644 --- a/interface/js/app/upload.js +++ b/interface/js/app/upload.js @@ -28,27 +28,11 @@ define(["jquery", "app/common", "app/libft"], ($, common, libft) => { "use strict"; const ui = {}; - let files = null; - let filesIdx = null; + const fileSet = {files: null, index: null}; let scanTextHeaders = {}; - function cleanTextUpload(source) { - $("#" + source + "TextSource").val(""); - } - - function uploadText(data, source, headers) { - let url = null; - if (source === "spam") { - url = "learnspam"; - } else if (source === "ham") { - url = "learnham"; - } else if (source === "fuzzyadd") { - url = "fuzzyadd"; - } else if (source === "fuzzydel") { - url = "fuzzydel"; - } else if (source === "scan") { - url = "checkv2"; - } + function uploadText(data, url, headers, method = "POST") { + const deferred = new $.Deferred(); function server() { if (common.getSelector("selSrv") === "All SERVERS" && @@ -64,34 +48,25 @@ define(["jquery", "app/common", "app/libft"], params: { processData: false, }, - method: "POST", + method: method, headers: headers, success: function (json, jqXHR) { - cleanTextUpload(source); common.alertMessage("alert-success", "Data successfully uploaded"); if (jqXHR.status !== 200) { common.alertMessage("alert-info", jqXHR.statusText); } + deferred.resolve(); }, + complete: () => deferred.resolve(), server: server() }); - } - - function enable_disable_scan_btn(disable) { - $("#scan button:not(#cleanScanHistory, #scanOptionsToggle, .ft-columns-btn)") - .prop("disabled", (disable || $.trim($("textarea").val()).length === 0)); - } - function setFileInputFiles(i) { - const dt = new DataTransfer(); - if (arguments.length) dt.items.add(files[i]); - $("#formFile").prop("files", dt.files); + return deferred.promise(); } - function readFile(callback, i) { - const reader = new FileReader(); - reader.readAsText(files[(arguments.length === 1) ? 0 : i]); - reader.onload = () => callback(reader.result); + function enable_disable_scan_btn(disable) { + $("#scan button:not(#cleanScanHistory, #deleteHashesBtn, #scanOptionsToggle, .ft-columns-btn)") + .prop("disabled", (disable || $.trim($("#scanMsgSource").val()).length === 0)); } function scanText(data) { @@ -112,7 +87,7 @@ define(["jquery", "app/common", "app/libft"], const {items} = o; common.symbols.scan.push(o.symbols[0]); - if (files) items[0].file = files[filesIdx].name; + if (fileSet.files) items[0].file = fileSet.files[fileSet.index].name; if (Object.prototype.hasOwnProperty.call(common.tables, "scan")) { common.tables.scan.rows.load(items, true); @@ -120,14 +95,16 @@ define(["jquery", "app/common", "app/libft"], require(["footable"], () => { libft.initHistoryTable(data, items, "scan", libft.columns_v2("scan"), true, () => { - if (files && filesIdx < files.length - 1) { - readFile((result) => { - if (filesIdx === files.length - 1) { + const {files} = fileSet; + if (files && fileSet.index < files.length - 1) { + common.fileUtils.readFile(files, (result) => { + const {index} = fileSet; + if (index === files.length - 1) { $("#scanMsgSource").val(result); - setFileInputFiles(filesIdx); + common.fileUtils.setFileInputFiles("#formFile", files, index); } scanText(result); - }, ++filesIdx); + }, ++fileSet.index); } else { enable_disable_scan_btn(); $("#cleanScanHistory, #scan .ft-columns-dropdown .btn-dropdown-apply") @@ -208,13 +185,6 @@ define(["jquery", "app/common", "app/libft"], }); enable_disable_scan_btn(); - $("textarea").on("input", () => { - enable_disable_scan_btn(); - if (files) { - files = null; - setFileInputFiles(); - } - }); $("#scanClean").on("click", () => { enable_disable_scan_btn(true); @@ -240,14 +210,17 @@ define(["jquery", "app/common", "app/libft"], const source = $(this).data("upload"); const data = $("#scanMsgSource").val(); if ($.trim(data).length > 0) { - if (source === "scan") { + if (source === "checkv2") { getScanTextHeaders(); scanText(data); } else if (source === "compute-fuzzy") { getFuzzyHashes(data); } else { let headers = {}; - if (source === "fuzzyadd") { + if (source === "learnham" || source === "learnspam") { + const classifier = $("#classifier").val(); + if (classifier) headers = {classifier: classifier}; + } else if (source === "fuzzyadd") { headers = { flag: $("#fuzzyFlagText").val(), weight: $("#fuzzyWeightText").val() @@ -265,39 +238,173 @@ define(["jquery", "app/common", "app/libft"], return false; }); - function fileInputHandler(obj) { - ({files} = obj); - filesIdx = 0; - if (files.length === 1) { - setFileInputFiles(0); - enable_disable_scan_btn(); - readFile((result) => { - $("#scanMsgSource").val(result); - enable_disable_scan_btn(); - }); + function setDelhashButtonsDisabled(disabled = true) { + ["#deleteHashesBtn", "#clearHashesBtn"].forEach((s) => $(s).prop("disabled", disabled)); + } + + /** + * Parse a textarea (or any input) value into an array of non-empty tokens. + * Splits on commas, semicolons or any whitespace (space, tab, newline). + * + * @param {string} selector - jQuery selector for the input element. + * @returns {string[]} - Trimmed, non-empty tokens. + */ + function parseHashes(selector) { + return $(selector).val() + .split(/[,\s;]+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + } + + $("#fuzzyDelList").on("input", () => { + const hasTokens = parseHashes("#fuzzyDelList").length > 0; + setDelhashButtonsDisabled(!hasTokens); + }); + + $("#deleteHashesBtn").on("click", () => { + $("#fuzzyDelList").prop("disabled", true); + setDelhashButtonsDisabled(); + $("#deleteHashesBtn").find(".btn-label").text("Deleting…"); + + const hashes = parseHashes("#fuzzyDelList"); + const promises = hashes.map((h) => { + const headers = { + flag: $("#fuzzyFlagText").val(), + Hash: h + }; + return uploadText(null, "fuzzydelhash", headers, "GET"); + }); + + $.when.apply($, promises).always(() => { + $("#fuzzyDelList").prop("disabled", false); + setDelhashButtonsDisabled(false); + $("#deleteHashesBtn").find(".btn-label").text("Delete hashes"); + }); + }); + + $("#clearHashesBtn").on("click", () => { + $("#fuzzyDelList").val("").focus(); + setDelhashButtonsDisabled(); + }); + + + function multiple_files_cb(files) { // eslint-disable-next-line no-alert - } else if (files.length < 10 || confirm("Are you sure you want to scan " + files.length + " files?")) { + if (files.length < 10 || confirm("Are you sure you want to scan " + files.length + " files?")) { getScanTextHeaders(); - readFile((result) => scanText(result)); + common.fileUtils.readFile(files, (result) => scanText(result)); } } - const dragoverClassList = "outline-dashed-primary bg-primary-subtle"; - $("#scanMsgSource") - .on("dragenter dragover dragleave drop", (e) => { - e.preventDefault(); - e.stopPropagation(); - }) - .on("dragenter dragover", () => { - $("#scanMsgSource").addClass(dragoverClassList); - }) - .on("dragleave drop", () => { - $("#scanMsgSource").removeClass(dragoverClassList); - }) - .on("drop", (e) => fileInputHandler(e.originalEvent.dataTransfer)); - - $("#formFile").on("change", (e) => fileInputHandler(e.target)); + common.fileUtils.setupFileHandling("#scanMsgSource", "#formFile", fileSet, enable_disable_scan_btn, multiple_files_cb); + + ui.getClassifiers = function () { + if (!common.read_only) { + const sel = $("#classifier").empty().append($("<option>", {value: "", text: "All classifiers"})); + common.query("/bayes/classifiers", { + success: function (data) { + data[0].data.forEach((c) => sel.append($("<option>", {value: c, text: c}))); + }, + server: common.getServer() + }); + } + }; + ui.getClassifiers(); + + + const fuzzyWidgets = [ + { + picker: "#fuzzy-flag-picker", + input: "#fuzzy-flag", + container: ($picker) => $picker.parent() + }, + { + picker: "#fuzzyFlagText-picker", + input: "#fuzzyFlagText", + container: ($picker) => $picker.closest("div.card") + } + ]; + + function toggleWidgets(showPicker, showInput) { + fuzzyWidgets.forEach(({picker, input}) => { + $(picker)[showPicker ? "show" : "hide"](); + $(input)[showInput ? "show" : "hide"](); + }); + } + + function setWidgetsDisabled(disable) { + fuzzyWidgets.forEach(({picker, container}) => { + container($(picker))[disable ? "addClass" : "removeClass"]("disabled"); + }); + } + + let lastFuzzyStoragesReq = {config_id: null, server: null}; + + ui.getFuzzyStorages = function () { + const server = common.getServer(); + + const servers = JSON.parse(sessionStorage.getItem("Credentials") || "{}"); + const config_id = servers[server]?.data?.config_id; + + if ((config_id && config_id === lastFuzzyStoragesReq.config_id) || + (!config_id && server === lastFuzzyStoragesReq.server)) { + return; + } + lastFuzzyStoragesReq = {config_id: config_id, server: server}; + + fuzzyWidgets.forEach(({picker, container}) => container($(picker)).removeAttr("title")); + + common.query("plugins/fuzzy/storages", { + success: function (data) { + const storages = data[0].data.storages || {}; + const hasWritableStorages = Object.keys(storages).some((name) => !storages[name].read_only); + + toggleWidgets(true, false); + setWidgetsDisabled(!hasWritableStorages); + + fuzzyWidgets.forEach(({picker, input}) => { + const $sel = $(picker); + + $sel.empty(); + + if (hasWritableStorages) { + Object.entries(storages).forEach(([name, info]) => { + if (!info.read_only) { + Object.entries(info.flags).forEach(([symbol, val]) => { + $sel.append($("<option>", {value: val, text: `${name}:${symbol} (${val})`})); + }); + } + }); + $(input).val($sel.val()); + $sel.off("change").on("change", () => $(input).val($sel.val())); + } else { + $sel.append($("<option>", {value: "", text: "No writable storages"})); + } + }); + }, + error: function (_result, _jqXHR, _textStatus, errorThrown) { + if (errorThrown === "fuzzy_check is not enabled") { + toggleWidgets(true, false); + setWidgetsDisabled(true); + + fuzzyWidgets.forEach(({picker, container}) => { + const $picker = $(picker); + $picker + .empty() + .append($("<option>", {value: "", text: "fuzzy_check disabled"})) + .show(); + container($picker) + .attr("title", "fuzzy_check module is not enabled in server configuration."); + }); + } else { + toggleWidgets(false, true); + setWidgetsDisabled(false); + } + }, + server: server + }); + }; return ui; }); |