diff options
Diffstat (limited to 'interface')
-rw-r--r-- | interface/css/rspamd.css | 26 | ||||
-rw-r--r-- | interface/index.html | 70 | ||||
-rw-r--r-- | interface/js/app/common.js | 130 | ||||
-rw-r--r-- | interface/js/app/rspamd.js | 7 | ||||
-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 | 284 |
7 files changed, 407 insertions, 121 deletions
diff --git a/interface/css/rspamd.css b/interface/css/rspamd.css index 896f92008..9f97a668b 100644 --- a/interface/css/rspamd.css +++ b/interface/css/rspamd.css @@ -95,6 +95,15 @@ fieldset[disabled] .btn { pointer-events: auto; cursor: not-allowed; } +.card.disabled, +.input-group.disabled { + cursor: not-allowed; + opacity: 0.65; +} +.card.disabled *, +.input-group.disabled * { + pointer-events: none; +} .w-1 { width: 1%; } @@ -418,19 +427,24 @@ table#symbolsTable input[type="number"] { .outline-dashed-primary { outline: 2px dashed var(--bs-primary); } -#scanMsgSource:placeholder-shown { +#scanMsgSource:placeholder-shown, +#selectorsMsgArea:placeholder-shown { background-image: url("../img/drop-area.svg"); background-repeat: no-repeat; background-position: center; opacity: 0.8; } -#scanMsgSource:not(:placeholder-shown) { background-image: none;} + +#scanMsgSource:not(:placeholder-shown), +#selectorsMsgArea:not(:placeholder-shown) { + background-image: none; +} .scorebar-spam { - background-color: rgba(240 0 0 / 0.1) !important; + background-color: rgb(240 0 0 / 0.1) !important; } .scorebar-ham { - background: rgba(100 230 80 / 0.1) !important; + background: rgb(100 230 80 / 0.1) !important; } .danger .icon { @@ -588,10 +602,10 @@ table#symbolsTable input[type="number"] { bottom: unset; } .codejar-linenumbers { - background: rgba(255 255 255 / 0.07) !important; + background: rgb(255 255 255 / 0.07) !important; } .codejar-linenumber { - color: rgba(120 120 120 / 1) !important; + color: rgb(120 120 120 / 1) !important; text-align: right; } .editor { diff --git a/interface/index.html b/interface/index.html index a759ac48f..30181e788 100644 --- a/interface/index.html +++ b/interface/index.html @@ -448,12 +448,13 @@ </div> <div class="card-footer d-md-flex justify-content-between py-1"> <div class="input-group d-inline-flex w-auto my-1"> - <button type="submit" class="btn btn-primary d-flex align-items-center" data-upload="scan"><i class="fas fa-search me-2"></i>Scan message</button> + <button type="submit" class="btn btn-primary d-flex align-items-center" data-upload="checkv2"><i class="fas fa-search me-2"></i>Scan message</button> <button class="btn btn-secondary d-flex align-items-center" id="scanOptionsToggle" data-bs-toggle="collapse" data-bs-target="#scanOptions"><i class="fas fa-bars me-2"></i>Options</button> </div> <div class="input-group d-inline-flex w-auto my-1"> <label for="fuzzy-flag" class="input-group-text">Flag</label> - <input id="fuzzy-flag" class="form-control" value="1" min="1" type="number"> + <select id="fuzzy-flag-picker" class="form-select"></select> + <input id="fuzzy-flag" class="form-control flex-grow-0" value="1" min="1" type="number"> <button class="btn btn-warning d-flex align-items-center" data-upload="compute-fuzzy"><i class="fas fa-hashtag me-2"></i>Compute fuzzy hashes</button> </div> <div class="float-end my-1"> @@ -474,32 +475,55 @@ </div> </div> <div class="card-body"> - <div class="row"> - <div class="col-lg-6"> + <div class="row g-3"> + <div class="col-lg-auto d-flex"> <div class="card bg-light shadow card-body card p-2"> <p>Learn Bayesian classifier:</p> <form> - <div class="btn-group"> - <button class="btn btn-success d-flex align-items-center" type="button" data-upload="ham" disabled><i class="fas fa-thumbs-up me-2"></i>Upload HAM</button> - <button class="btn btn-danger d-flex align-items-center" type="button" data-upload="spam" disabled><i class="fas fa-thumbs-down me-2"></i>Upload SPAM</button> + <div class="d-flex flex-wrap flex-lg-column align-items-start align-items-lg-stretch gap-2"> + <select id="classifier" class="form-select w-auto"></select> + <div class="btn-group"> + <button class="btn btn-success d-flex align-items-center" type="button" data-upload="learnham" disabled><i class="fas fa-thumbs-up me-2"></i>Upload HAM</button> + <button class="btn btn-danger d-flex align-items-center" type="button" data-upload="learnspam" disabled><i class="fas fa-thumbs-down me-2"></i>Upload SPAM</button> + </div> </div> </form> </div> </div> - <div class="col-lg-6"> - <div class="card bg-light shadow card-body card p-2"> - <p>Learn Fuzzy storage:</p> - <form class="d-flex"> - <div class="d-flex align-items-center"> - <label for="fuzzyFlagText">Flag:</label> - <input name="fuzzyFlagText" id="fuzzyFlagText" class="form-control ms-1" type="number" value="1"/> + <div class="col-lg d-flex"> + <div class="card bg-light shadow card-body p-2"> + <p>Fuzzy hash storage management:</p> + <div class="row g-2 align-items-center"> + <div class="col-auto d-flex align-items-center me-1"> + <label for="fuzzyFlagText" class="me-1">Flag:</label> + <select id="fuzzyFlagText-picker" class="form-select"></select> + <input id="fuzzyFlagText" class="form-control" type="number" value="1"/> </div> - <div class="d-flex align-items-center ms-2"> - <label for="fuzzyWeightText">Weight:</label> - <input name="fuzzyWeightText" id="fuzzyWeightText" class="form-control ms-1" type="number" value="1"/> + <div class="col-auto d-flex align-items-center me-2"> + <label for="fuzzyWeightText" class="me-1">Weight:</label> + <input id="fuzzyWeightText" class="form-control" type="number" value="1"/> </div> - <button class="btn btn-warning ms-2 d-flex align-items-center" data-upload="fuzzy" disabled><i class="fas fa-upload me-2"></i>Upload FUZZY</button> - </form> + <div class="col-auto"> + <button class="btn btn-warning me-1" data-upload="fuzzyadd" disabled><i class="fas fa-circle-plus me-2"></i>Add to storage</button> + <button class="btn btn-danger" data-upload="fuzzydel" disabled><i class="fas fa-trash-can me-2"></i>Delete from storage</button> + </div> + </div> + <div class="row mt-3"> + <div class="col"> + <label for="fuzzyDelList" class="form-label">Hashes to delete</label> + <textarea class="form-control" id="fuzzyDelList" rows="3" placeholder="Enter one hash per line, or separate with commas, semicolons, or spaces."></textarea> + </div> + </div> + <div class="row mt-2"> + <div class="col d-flex justify-content-end"> + <button class="btn btn-danger me-2" id="deleteHashesBtn" disabled> + <i class="fas fa-trash-can me-2"></i><span class="btn-label">Delete hashes</span> + </button> + <button class="btn btn-secondary" id="clearHashesBtn" disabled> + <i class="fas fa-eraser me-2"></i>Clear + </button> + </div> + </div> </div> </div> </div> @@ -510,7 +534,7 @@ <div class="card-header text-secondary py-2 d-flex align-items-center"> <span class="icon me-3"><i class="fas fa-hashtag"></i></span> <span class="h6 fw-bolder my-auto">Fuzzy hashes</span> - <button type="button" class="card-close-btn btn-close float-end" aria-label="Close"></button> + <button type="button" class="card-close-btn btn-close ms-auto" aria-label="Close"></button> </div> <div class="card-body p-0 table-responsive"> <table class="table status-table table-sm table-bordered text-nowrap mb-0" id="hashTable"> @@ -561,6 +585,10 @@ <div class="card-header text-secondary py-2 d-flex align-items-center"> <span class="icon me-3"><i class="fas fa-envelope"></i></span> <span class="h6 fw-bolder my-auto">Test Rspamd selectors</span> + <div class="d-flex input-group-sm align-items-center ms-auto"> + <label for="formFile" class="col-auto col-form-label-sm me-1">Choose a file:</label> + <input class="form-control form-control-sm btn btn-secondary" id="selectorsFile" type="file"> + </div> </div> <div class="card-body p-0"> <div class="row h-100 m-0" id="row-main"> @@ -589,7 +617,7 @@ <div class="col"> <div class="form-group"> <label class="form-label" for="selectorsMsgArea">Message source:</label> - <textarea class="form-control" id="selectorsMsgArea" rows="9" placeholder="Paste raw message source"></textarea> + <textarea class="form-control" id="selectorsMsgArea" rows="9" placeholder='Paste raw message source, drag and drop files here or use "Browse..." button.'></textarea> </div> <button class="btn btn-secondary d-flex align-items-center float-end" id="selectorsMsgClean"><i class="fas fa-trash-alt me-2"></i>Clean form</button> </div> 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..cb7fb8ace 100644 --- a/interface/js/app/rspamd.js +++ b/interface/js/app/rspamd.js @@ -176,7 +176,10 @@ define(["jquery", "app/common", "stickytabs", "visibility", require(["app/symbols"], (module) => module.getSymbols()); break; case "#scan_nav": - require(["app/upload"]); + require(["app/upload"], (module) => { + module.getClassifiers(); + module.getFuzzyStorages(); + }); break; case "#selectors_nav": require(["app/selectors"], (module) => module.displayUI()); @@ -236,6 +239,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 a484a41aa..f95d5cc90 100644 --- a/interface/js/app/upload.js +++ b/interface/js/app/upload.js @@ -28,25 +28,15 @@ define(["jquery", "app/common", "app/libft"], ($, common, libft) => { "use strict"; const ui = {}; - let files = null; - let filesIdx = null; + const fileSet = {files: null, index: null}; + const lastReqContext = { + classifiers: {config_id: null, server: null}, + storages: {config_id: null, server: 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 === "fuzzy") { - url = "fuzzyadd"; - } 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" && @@ -62,34 +52,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) { @@ -110,7 +91,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); @@ -118,14 +99,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") @@ -206,13 +189,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); @@ -238,18 +214,25 @@ 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 === "fuzzy") { + 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() }; + } else if (source === "fuzzydel") { + headers = { + flag: $("#fuzzyFlagText").val(), + }; } uploadText(data, source, headers); } @@ -259,39 +242,190 @@ 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)); + } + } + + common.fileUtils.setupFileHandling("#scanMsgSource", "#formFile", fileSet, enable_disable_scan_btn, multiple_files_cb); + + + /** + * Returns `true` if we should skip the request as configuration is not changed, + * otherwise bumps the request context cache and returns `false`. + * + * @param {string} server + * Name of the currently selected Rspamd neighbour. + * @param {"classifiers"|"storages"} key + * Which endpoint’s cache to check. + * @returns {boolean} + */ + function shouldSkipRequest(server, key) { + const servers = JSON.parse(sessionStorage.getItem("Credentials") || "{}"); + const config_id = servers[server]?.data?.config_id; + const last = lastReqContext[key]; + + if ((config_id && config_id === last.config_id) || + (!config_id && server === last.server)) { + return true; + } + + lastReqContext[key] = {config_id, server}; + return false; + } + + ui.getClassifiers = function () { + const server = common.getServer(); + if (shouldSkipRequest(server, "classifiers")) return; + + 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: server + }); + } + }; + + + 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"](); + }); } - 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)); + function setWidgetsDisabled(disable) { + fuzzyWidgets.forEach(({picker, container}) => { + container($(picker))[disable ? "addClass" : "removeClass"]("disabled"); + }); + } + + ui.getFuzzyStorages = function () { + const server = common.getServer(); + if (shouldSkipRequest(server, "storages")) return; + + 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; }); |