aboutsummaryrefslogtreecommitdiffstats
path: root/interface/js
diff options
context:
space:
mode:
Diffstat (limited to 'interface/js')
-rw-r--r--interface/js/app/common.js130
-rw-r--r--interface/js/app/rspamd.js4
-rw-r--r--interface/js/app/selectors.js10
-rw-r--r--interface/js/app/symbols.js1
-rw-r--r--interface/js/app/upload.js261
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;
});