diff options
Diffstat (limited to 'interface/js/app')
-rw-r--r-- | interface/js/app/common.js | 233 | ||||
-rw-r--r-- | interface/js/app/config.js | 246 | ||||
-rw-r--r-- | interface/js/app/graph.js | 252 | ||||
-rw-r--r-- | interface/js/app/history.js | 306 | ||||
-rw-r--r-- | interface/js/app/libft.js | 524 | ||||
-rw-r--r-- | interface/js/app/rspamd.js | 486 | ||||
-rw-r--r-- | interface/js/app/selectors.js | 145 | ||||
-rw-r--r-- | interface/js/app/stats.js | 372 | ||||
-rw-r--r-- | interface/js/app/symbols.js | 260 | ||||
-rw-r--r-- | interface/js/app/upload.js | 243 |
10 files changed, 3067 insertions, 0 deletions
diff --git a/interface/js/app/common.js b/interface/js/app/common.js new file mode 100644 index 0000000..ea6102f --- /dev/null +++ b/interface/js/app/common.js @@ -0,0 +1,233 @@ +/* global jQuery */ + +define(["jquery", "nprogress"], + ($, NProgress) => { + "use strict"; + const ui = { + chartLegend: [ + {label: "reject", color: "#FF0000"}, + {label: "soft reject", color: "#BF8040"}, + {label: "rewrite subject", color: "#FF6600"}, + {label: "add header", color: "#FFAD00"}, + {label: "greylist", color: "#436EEE"}, + {label: "no action", color: "#66CC00"} + ], + locale: (localStorage.getItem("selected_locale") === "custom") ? localStorage.getItem("custom_locale") : null, + neighbours: [], + page_size: { + scan: 25, + errors: 25, + history: 25 + }, + symbols: { + scan: [], + history: [] + }, + tables: {} + }; + + + NProgress.configure({ + minimum: 0.01, + showSpinner: false, + }); + + function getPassword() { + return sessionStorage.getItem("Password"); + } + + function alertMessage(alertClass, alertText) { + const a = $("<div class=\"alert " + alertClass + " alert-dismissible fade in show\">" + + "<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\" title=\"Dismiss\"></button>" + + "<strong>" + alertText + "</strong>"); + $(".notification-area").append(a); + + setTimeout(() => { + $(a).fadeTo(500, 0).slideUp(500, function () { + $(this).alert("close"); + }); + }, 5000); + } + + function queryServer(neighbours_status, ind, req_url, o) { + neighbours_status[ind].checked = false; + neighbours_status[ind].data = {}; + neighbours_status[ind].status = false; + const req_params = { + jsonp: false, + data: o.data, + headers: $.extend({Password: getPassword()}, o.headers), + url: neighbours_status[ind].url + req_url, + xhr: function () { + const xhr = $.ajaxSettings.xhr(); + // Download progress + if (req_url !== "neighbours") { + xhr.addEventListener("progress", (e) => { + if (e.lengthComputable) { + neighbours_status[ind].percentComplete = e.loaded / e.total; + const percentComplete = neighbours_status + .reduce((prev, curr) => (curr.percentComplete ? curr.percentComplete + prev : prev), 0); + NProgress.set(percentComplete / neighbours_status.length); + } + }, false); + } + return xhr; + }, + success: function (json) { + neighbours_status[ind].checked = true; + neighbours_status[ind].status = true; + neighbours_status[ind].data = json; + }, + error: function (jqXHR, textStatus, errorThrown) { + neighbours_status[ind].checked = true; + function errorMessage() { + alertMessage("alert-error", neighbours_status[ind].name + " > " + + (o.errorMessage ? o.errorMessage : "Request failed") + + (errorThrown ? ": " + errorThrown : "")); + } + if (o.error) { + o.error(neighbours_status[ind], + jqXHR, textStatus, errorThrown); + } else if (o.errorOnceId) { + const alert_status = o.errorOnceId + neighbours_status[ind].name; + if (!(alert_status in sessionStorage)) { + sessionStorage.setItem(alert_status, true); + errorMessage(); + } + } else { + errorMessage(); + } + }, + complete: function (jqXHR) { + if (neighbours_status.every((elt) => elt.checked)) { + if (neighbours_status.some((elt) => elt.status)) { + if (o.success) { + o.success(neighbours_status, jqXHR); + } else { + alertMessage("alert-success", "Request completed"); + } + } else { + alertMessage("alert-error", "Request failed"); + } + if (o.complete) o.complete(); + NProgress.done(); + } + }, + statusCode: o.statusCode + }; + if (o.method) { + req_params.method = o.method; + } + if (o.params) { + $.each(o.params, (k, v) => { + req_params[k] = v; + }); + } + $.ajax(req_params); + } + + + // Public functions + + ui.alertMessage = alertMessage; + ui.getPassword = getPassword; + + // Get selectors' current state + ui.getSelector = function (id) { + const e = document.getElementById(id); + return e.options[e.selectedIndex].value; + }; + + /** + * @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. + * + * @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. + * + * @returns {undefined} + */ + ui.query = function (url, options) { + // Force options to be an object + const o = options || {}; + Object.keys(o).forEach((option) => { + if (["complete", "data", "error", "errorMessage", "errorOnceId", "headers", "method", "params", "server", + "statusCode", "success"] + .indexOf(option) < 0) { + throw new Error("Unknown option: " + option); + } + }); + + let neighbours_status = [{ + name: "local", + host: "local", + url: "", + }]; + o.server = o.server || ui.getSelector("selSrv"); + if (o.server === "All SERVERS") { + queryServer(neighbours_status, 0, "neighbours", { + success: function (json) { + const [{data}] = json; + if (jQuery.isEmptyObject(data)) { + ui.neighbours = { + local: { + host: window.location.host, + url: window.location.origin + window.location.pathname + } + }; + } else { + ui.neighbours = data; + } + neighbours_status = []; + $.each(ui.neighbours, (ind) => { + neighbours_status.push({ + name: ind, + host: ui.neighbours[ind].host, + url: ui.neighbours[ind].url, + }); + }); + $.each(neighbours_status, (ind) => { + queryServer(neighbours_status, ind, url, o); + }); + }, + errorMessage: "Cannot receive neighbours data" + }); + } else { + if (o.server !== "local") { + neighbours_status = [{ + name: o.server, + host: ui.neighbours[o.server].host, + url: ui.neighbours[o.server].url, + }]; + } + queryServer(neighbours_status, 0, url, o); + } + }; + + ui.escapeHTML = function (string) { + const htmlEscaper = /[&<>"'/`=]/g; + const htmlEscapes = { + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + "/": "/", + "`": "`", + "=": "=" + }; + return String(string).replace(htmlEscaper, (match) => htmlEscapes[match]); + }; + + return ui; + }); diff --git a/interface/js/app/config.js b/interface/js/app/config.js new file mode 100644 index 0000000..6be1075 --- /dev/null +++ b/interface/js/app/config.js @@ -0,0 +1,246 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global require */ + +define(["jquery", "app/common"], + ($, common) => { + "use strict"; + const ui = {}; + + ui.getActions = function getActions(checked_server) { + common.query("actions", { + success: function (data) { + $("#actionsFormField").empty(); + const items = []; + $.each(data[0].data, (i, item) => { + const actionsOrder = ["greylist", "add header", "rewrite subject", "reject"]; + const idx = actionsOrder.indexOf(item.action); + if (idx >= 0) { + items.push({ + idx: idx, + html: + '<div class="form-group">' + + '<label class="col-form-label col-md-2 float-start">' + item.action + "</label>" + + '<div class="controls slider-controls col-md-10">' + + '<input class="action-scores form-control" data-id="action" type="number" value="' + + item.value + '">' + + "</div>" + + "</div>" + }); + } + }); + + items.sort((a, b) => a.idx - b.idx); + + $("#actionsFormField").html( + items.map((e) => e.html).join("")); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }; + + ui.saveActions = function (server) { + function descending(arr) { + let desc = true; + const filtered = arr.filter((el) => el !== null); + for (let i = 0; i < filtered.length - 1; i++) { + if (filtered[i + 1] >= filtered[i]) { + desc = false; + break; + } + } + return desc; + } + + const elts = (function () { + const values = []; + const inputs = $("#actionsForm :input[data-id=\"action\"]"); + // Rspamd order: [spam, rewrite_subject, probable_spam, greylist] + values[0] = parseFloat(inputs[3].value); + values[1] = parseFloat(inputs[2].value); + values[2] = parseFloat(inputs[1].value); + values[3] = parseFloat(inputs[0].value); + + return JSON.stringify(values); + }()); + // String to array for comparison + const eltsArray = JSON.parse(elts); + if (eltsArray[0] < 0) { + common.alertMessage("alert-modal alert-error", "Spam can not be negative"); + } else if (eltsArray[1] < 0) { + common.alertMessage("alert-modal alert-error", "Rewrite subject can not be negative"); + } else if (eltsArray[2] < 0) { + common.alertMessage("alert-modal alert-error", "Probable spam can not be negative"); + } else if (eltsArray[3] < 0) { + common.alertMessage("alert-modal alert-error", "Greylist can not be negative"); + } else if (descending(eltsArray)) { + common.query("saveactions", { + method: "POST", + params: { + data: elts, + dataType: "json" + }, + server: server + }); + } else { + common.alertMessage("alert-modal alert-error", "Incorrect order of actions thresholds"); + } + }; + + ui.getMaps = function (checked_server) { + const $listmaps = $("#listMaps"); + $listmaps.closest(".card").hide(); + common.query("maps", { + success: function (json) { + const [{data}] = json; + $listmaps.empty(); + $("#modalBody").empty(); + const $tbody = $("<tbody>"); + + $.each(data, (i, item) => { + let $td = '<td><span class="badge text-bg-secondary">Read</span></td>'; + if (!(item.editable === false || common.read_only)) { + $td = $($td).append(' <span class="badge text-bg-success">Write</span>'); + } + const $tr = $("<tr>").append($td); + + const $span = $('<span class="map-link" data-bs-toggle="modal" data-bs-target="#modalDialog">' + + item.uri + "</span>").data("item", item); + $span.wrap("<td>").parent().appendTo($tr); + $("<td>" + item.description + "</td>").appendTo($tr); + $tr.appendTo($tbody); + }); + $tbody.appendTo($listmaps); + $listmaps.closest(".card").show(); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }; + + + let jar = {}; + const editor = { + advanced: { + codejar: true, + elt: "div", + class: "editor language-clike", + readonly_attr: {contenteditable: false}, + }, + basic: { + elt: "textarea", + class: "form-control map-textarea", + readonly_attr: {readonly: true}, + } + }; + let mode = "advanced"; + + // Modal form for maps + $(document).on("click", "[data-bs-toggle=\"modal\"]", function () { + const checked_server = common.getSelector("selSrv"); + const item = $(this).data("item"); + common.query("getmap", { + headers: { + Map: item.map + }, + success: function (data) { + // Highlighting a large amount of text is unresponsive + mode = (new Blob([data[0].data]).size > 5120) ? "basic" : $("input[name=editorMode]:checked").val(); + + $("<" + editor[mode].elt + ' id="editor" class="' + editor[mode].class + '" data-id="' + item.map + + '"></' + editor[mode].elt + ">").appendTo("#modalBody"); + + if (editor[mode].codejar) { + require(["codejar", "linenumbers", "prism"], (CodeJar, withLineNumbers, Prism) => { + jar = new CodeJar( + document.querySelector("#editor"), + withLineNumbers((el) => Prism.highlightElement(el)) + ); + jar.updateCode(data[0].data); + }); + } else { + document.querySelector("#editor").innerHTML = common.escapeHTML(data[0].data); + } + + let icon = "fa-edit"; + if (item.editable === false || common.read_only) { + $("#editor").attr(editor[mode].readonly_attr); + icon = "fa-eye"; + $("#modalSaveGroup").hide(); + } else { + $("#modalSaveGroup").show(); + } + $("#modalDialog .modal-header").find("[data-fa-i2svg]").addClass(icon); + $("#modalTitle").html(item.uri); + + $("#modalDialog").modal("show"); + }, + errorMessage: "Cannot receive maps data", + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + return false; + }); + $("#modalDialog").on("hidden.bs.modal", () => { + if (editor[mode].codejar) { + jar.destroy(); + $(".codejar-wrap").remove(); + } else { + $("#editor").remove(); + } + }); + + $("#saveActionsBtn").on("click", () => { + ui.saveActions(); + }); + $("#saveActionsClusterBtn").on("click", () => { + ui.saveActions("All SERVERS"); + }); + + function saveMap(server) { + common.query("savemap", { + success: function () { + common.alertMessage("alert-success", "Map data successfully saved"); + $("#modalDialog").modal("hide"); + }, + errorMessage: "Save map error", + method: "POST", + headers: { + Map: $("#editor").data("id"), + }, + params: { + data: editor[mode].codejar ? jar.toString() : $("#editor").val(), + dataType: "text", + }, + server: server + }); + } + $("#modalSave").on("click", () => { + saveMap(); + }); + $("#modalSaveAll").on("click", () => { + saveMap("All SERVERS"); + }); + + return ui; + }); diff --git a/interface/js/app/graph.js b/interface/js/app/graph.js new file mode 100644 index 0000000..71306f4 --- /dev/null +++ b/interface/js/app/graph.js @@ -0,0 +1,252 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + Copyright (C) 2017 Alexander Moisseev + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global FooTable */ + +define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"], + ($, common, D3Evolution, D3Pie, d3) => { + "use strict"; + + const rrd_pie_config = { + cornerRadius: 2, + size: { + canvasWidth: 400, + canvasHeight: 180, + pieInnerRadius: "50%", + pieOuterRadius: "80%" + }, + labels: { + outer: { + format: "none" + }, + inner: { + hideWhenLessThanPercentage: 8, + offset: 0 + }, + }, + padAngle: 0.02, + pieCenterOffset: { + x: -120, + y: 10, + }, + total: { + enabled: true + }, + }; + + const ui = {}; + let prevUnit = "msg/s"; + + ui.draw = function (graphs, neighbours, checked_server, type) { + const graph_options = { + title: "Rspamd throughput", + width: 1060, + height: 370, + yAxisLabel: "Message rate, msg/s", + + legend: { + space: 140, + entries: common.chartLegend + } + }; + + function initGraph() { + const graph = new D3Evolution("graph", $.extend({}, graph_options, { + yScale: common.getSelector("selYScale"), + type: common.getSelector("selType"), + interpolate: common.getSelector("selInterpolate"), + convert: common.getSelector("selConvert"), + })); + $("#selYScale").change(function () { + graph.yScale(this.value); + }); + $("#selConvert").change(function () { + graph.convert(this.value); + }); + $("#selType").change(function () { + graph.type(this.value); + }); + $("#selInterpolate").change(function () { + graph.interpolate(this.value); + }); + + return graph; + } + + function getRrdSummary(json, scaleFactor) { + const xExtents = d3.extent(d3.merge(json), (d) => d.x); + const timeInterval = xExtents[1] - xExtents[0]; + + let total = 0; + const rows = json.map((curr, i) => { + // Time intervals that don't have data are excluded from average calculation as d3.mean()ignores nulls + const avg = d3.mean(curr, (d) => d.y); + // To find an integral on the whole time interval we need to convert nulls to zeroes + // eslint-disable-next-line no-bitwise + const value = d3.mean(curr, (d) => Number(d.y)) * timeInterval / scaleFactor ^ 0; + const yExtents = d3.extent(curr, (d) => d.y); + + total += value; + return { + label: graph_options.legend.entries[i].label, + value: value, + min: Number(yExtents[0].toFixed(6)), + avg: Number(avg.toFixed(6)), + max: Number(yExtents[1].toFixed(6)), + last: Number(curr[curr.length - 1].y.toFixed(6)), + color: graph_options.legend.entries[i].color, + }; + }, []); + + return { + rows: rows, + total: total + }; + } + + function initSummaryTable(rows, unit) { + common.tables.rrd_summary = FooTable.init("#rrd-table", { + sorting: { + enabled: true + }, + columns: [ + {name: "label", title: "Action"}, + {name: "value", title: "Messages", defaultContent: ""}, + {name: "min", title: "Minimum, <span class=\"unit\">" + unit + "</span>", defaultContent: ""}, + {name: "avg", title: "Average, <span class=\"unit\">" + unit + "</span>", defaultContent: ""}, + {name: "max", title: "Maximum, <span class=\"unit\">" + unit + "</span>", defaultContent: ""}, + {name: "last", title: "Last, " + unit}, + ], + rows: rows.map((curr, i) => ({ + options: { + style: { + color: graph_options.legend.entries[i].color + } + }, + value: curr + }), []) + }); + } + + function drawRrdTable(rows, unit) { + if (Object.prototype.hasOwnProperty.call(common.tables, "rrd_summary")) { + $.each(common.tables.rrd_summary.rows.all, (i, row) => { + row.val(rows[i], false, true); + }); + } else { + initSummaryTable(rows, unit); + } + } + + function updateWidgets(data) { + let rrd_summary = {rows: []}; + let unit = "msg/s"; + + if (data) { + // Autoranging + let scaleFactor = 1; + const yMax = d3.max(d3.merge(data), (d) => d.y); + if (yMax < 1) { + scaleFactor = 60; + unit = "msg/min"; + data.forEach((s) => { + s.forEach((d) => { + if (d.y !== null) { d.y *= scaleFactor; } + }); + }); + } + + rrd_summary = getRrdSummary(data, scaleFactor); + } + + if (!graphs.rrd_pie) graphs.rrd_pie = new D3Pie("rrd-pie", rrd_pie_config); + graphs.rrd_pie.data(rrd_summary.rows); + + graphs.graph.data(data); + if (unit !== prevUnit) { + graphs.graph.yAxisLabel("Message rate, " + unit); + $(".unit").text(unit); + prevUnit = unit; + } + drawRrdTable(rrd_summary.rows, unit); + document.getElementById("rrd-total-value").innerHTML = rrd_summary.total; + } + + if (!graphs.graph) { + graphs.graph = initGraph(); + } + + + common.query("graph", { + success: function (req_data) { + let data = null; + const neighbours_data = req_data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + + if (neighbours_data.length === 1) { + [data] = neighbours_data; + } else { + let time_match = true; + neighbours_data.reduce((res, curr, _, arr) => { + if ((curr[0][0].x !== res[0][0].x) || + (curr[0][curr[0].length - 1].x !== res[0][res[0].length - 1].x)) { + time_match = false; + common.alertMessage("alert-error", + "Neighbours time extents do not match. Check if time is synchronized on all servers."); + arr.splice(1); // Break out of .reduce() by mutating the source array + } + return curr; + }); + + if (time_match) { + data = neighbours_data.reduce((res, curr) => curr.map((action, j) => action.map((d, i) => ({ + x: d.x, + y: (res[j][i].y === null) ? d.y : res[j][i].y + d.y + })))); + } + } + updateWidgets(data); + }, + complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + errorMessage: "Cannot receive throughput data", + errorOnceId: "alerted_graph_", + data: {type: type} + }); + }; + + + // Handling mouse events on overlapping elements + $("#rrd-pie").mouseover(() => { + $("#rrd-pie,#rrd-pie-tooltip").css("z-index", "200"); + $("#rrd-table_toggle").css("z-index", "300"); + }); + $("#rrd-table_toggle").mouseover(() => { + $("#rrd-pie,#rrd-pie-tooltip").css("z-index", "0"); + $("#rrd-table_toggle").css("z-index", "0"); + }); + + return ui; + }); diff --git a/interface/js/app/history.js b/interface/js/app/history.js new file mode 100644 index 0000000..0d953ec --- /dev/null +++ b/interface/js/app/history.js @@ -0,0 +1,306 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global FooTable */ + +define(["jquery", "app/common", "app/libft", "footable"], + ($, common, libft) => { + "use strict"; + const ui = {}; + let prevVersion = null; + + function process_history_legacy(data) { + const items = []; + + function compare(e1, e2) { return e1.name.localeCompare(e2.name); } + + $("#selSymOrder_history, label[for='selSymOrder_history']").hide(); + + $.each(data, (i, item) => { + item.time = libft.unix_time_format(item.unix_time); + libft.preprocess_item(item); + item.symbols = Object.keys(item.symbols) + .map((key) => item.symbols[key]) + .sort(compare) + .map((e) => e.name) + .join(", "); + item.time = { + value: libft.unix_time_format(item.unix_time), + options: { + sortValue: item.unix_time + } + }; + + items.push(item); + }); + + return {items: items}; + } + + function columns_legacy() { + return [{ + name: "id", + title: "ID", + style: { + width: 300, + maxWidth: 300, + overflow: "hidden", + textOverflow: "ellipsis", + wordBreak: "keep-all", + whiteSpace: "nowrap" + } + }, { + name: "ip", + title: "IP address", + breakpoints: "xs sm", + style: {width: 150, maxWidth: 150} + }, { + name: "action", + title: "Action", + style: {width: 110, maxWidth: 110} + }, { + name: "score", + title: "Score", + style: {maxWidth: 110}, + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "symbols", + title: "Symbols", + breakpoints: "all", + style: {width: 550, maxWidth: 550} + }, { + name: "size", + title: "Message size", + breakpoints: "xs sm", + style: {width: 120, maxWidth: 120}, + formatter: libft.formatBytesIEC + }, { + name: "scan_time", + title: "Scan time", + breakpoints: "xs sm", + style: {maxWidth: 80}, + sortValue: function (val) { return Number(val); } + }, { + sorted: true, + direction: "DESC", + name: "time", + title: "Time", + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "user", + title: "Authenticated user", + breakpoints: "xs sm", + style: {width: 200, maxWidth: 200} + }]; + } + + const columns = { + 2: libft.columns_v2("history"), + legacy: columns_legacy() + }; + + function process_history_data(data) { + const process_functions = { + 2: libft.process_history_v2, + legacy: process_history_legacy + }; + let pf = process_functions.legacy; + + if (data.version) { + const strkey = data.version.toString(); + if (process_functions[strkey]) { + pf = process_functions[strkey]; + } + } + + return pf(data, "history"); + } + + function get_history_columns(data) { + let func = columns.legacy; + + if (data.version) { + const strkey = data.version.toString(); + if (columns[strkey]) { + func = columns[strkey]; + } + } + + return func; + } + + ui.getHistory = function () { + common.query("history", { + success: function (req_data) { + function differentVersions(neighbours_data) { + const dv = neighbours_data.some((e) => e.version !== neighbours_data[0].version); + if (dv) { + common.alertMessage("alert-error", + "Neighbours history backend versions do not match. Cannot display history."); + return true; + } + return false; + } + + const neighbours_data = req_data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + if (neighbours_data.length && !differentVersions(neighbours_data)) { + let data = {}; + const [{version}] = neighbours_data; + if (version) { + data.rows = [].concat.apply([], neighbours_data + .map((e) => e.rows)); + data.version = version; + $("#legacy-history-badge").hide(); + } else { + // Legacy version + data = [].concat.apply([], neighbours_data); + $("#legacy-history-badge").show(); + } + const o = process_history_data(data); + const {items} = o; + common.symbols.history = o.symbols; + + if (Object.prototype.hasOwnProperty.call(common.tables, "history") && + version === prevVersion) { + common.tables.history.rows.load(items); + } else { + libft.destroyTable("history"); + // Is there a way to get an event when the table is destroyed? + setTimeout(() => { + libft.initHistoryTable(data, items, "history", get_history_columns(data), false); + }, 200); + } + prevVersion = version; + } else { + libft.destroyTable("history"); + } + }, + complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + errorMessage: "Cannot receive history", + }); + }; + + function initErrorsTable(rows) { + common.tables.errors = FooTable.init("#errorsLog", { + columns: [ + {sorted: true, + direction: "DESC", + name: "ts", + title: "Time", + style: {width: 300, maxWidth: 300}, + sortValue: function (val) { return Number(val.options.sortValue); }}, + {name: "type", + title: "Worker type", + breakpoints: "xs sm", + style: {width: 150, maxWidth: 150}}, + {name: "pid", + title: "PID", + breakpoints: "xs sm", + style: {width: 110, maxWidth: 110}}, + {name: "module", title: "Module"}, + {name: "id", title: "Internal ID"}, + {name: "message", title: "Message", breakpoints: "xs sm"}, + ], + rows: rows, + paging: { + enabled: true, + limit: 5, + size: common.page_size.errors + }, + filtering: { + enabled: true, + position: "left", + connectors: false + }, + sorting: { + enabled: true + } + }); + } + + ui.getErrors = function () { + if (common.read_only) return; + + common.query("errors", { + success: function (data) { + const neighbours_data = data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + const rows = [].concat.apply([], neighbours_data); + $.each(rows, (i, item) => { + item.ts = { + value: libft.unix_time_format(item.ts), + options: { + sortValue: item.ts + } + }; + }); + if (Object.prototype.hasOwnProperty.call(common.tables, "errors")) { + common.tables.errors.rows.load(rows); + } else { + initErrorsTable(rows); + } + } + }); + + $("#updateErrors").off("click"); + $("#updateErrors").on("click", (e) => { + e.preventDefault(); + ui.getErrors(); + }); + }; + + + libft.set_page_size("history", $("#history_page_size").val()); + libft.bindHistoryTableEventHandlers("history", 8); + + $("#updateHistory").off("click"); + $("#updateHistory").on("click", (e) => { + e.preventDefault(); + ui.getHistory(); + }); + + // @reset history log + $("#resetHistory").off("click"); + $("#resetHistory").on("click", (e) => { + e.preventDefault(); + if (!confirm("Are you sure you want to reset history log?")) { // eslint-disable-line no-alert + return; + } + libft.destroyTable("history"); + libft.destroyTable("errors"); + + common.query("historyreset", { + success: function () { + ui.getHistory(); + ui.getErrors(); + }, + errorMessage: "Cannot reset history log" + }); + }); + + return ui; + }); diff --git a/interface/js/app/libft.js b/interface/js/app/libft.js new file mode 100644 index 0000000..c69f74d --- /dev/null +++ b/interface/js/app/libft.js @@ -0,0 +1,524 @@ +/* global FooTable */ + +define(["jquery", "app/common", "footable"], + ($, common) => { + "use strict"; + const ui = {}; + + let pageSizeTimerId = null; + let pageSizeInvocationCounter = 0; + + function get_compare_function(table) { + const compare_functions = { + magnitude: function (e1, e2) { + return Math.abs(e2.score) - Math.abs(e1.score); + }, + name: function (e1, e2) { + return e1.name.localeCompare(e2.name); + }, + score: function (e1, e2) { + return e2.score - e1.score; + } + }; + + return compare_functions[common.getSelector("selSymOrder_" + table)]; + } + + function sort_symbols(o, compare_function) { + return Object.keys(o) + .map((key) => o[key]) + .sort(compare_function) + .map((e) => e.str) + .join("<br>\n"); + } + + + // Public functions + + ui.formatBytesIEC = function (bytes) { + // FooTable represents data as text even column type is "number". + if (!Number.isInteger(Number(bytes)) || bytes < 0) return "NaN"; + + const base = 1024; + const exponent = Math.floor(Math.log(bytes) / Math.log(base)); + + if (exponent > 8) return "∞"; + + const value = parseFloat((bytes / (base ** exponent)).toPrecision(3)); + let unit = "BKMGTPEZY"[exponent]; + if (exponent) unit += "iB"; + + return value + " " + unit; + }; + + ui.columns_v2 = function (table) { + return [{ + name: "id", + title: "ID", + style: { + minWidth: 130, + overflow: "hidden", + textOverflow: "ellipsis", + wordBreak: "break-all", + whiteSpace: "normal" + } + }, { + name: "ip", + title: "IP address", + breakpoints: "xs sm md", + style: { + "minWidth": "calc(7.6em + 8px)", + "word-break": "break-all" + }, + // Normalize IPv4 + sortValue: (ip) => ((typeof ip === "string") ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0") + }, { + name: "sender_mime", + title: "[Envelope From] From", + breakpoints: "xs sm md", + style: { + "minWidth": 100, + "maxWidth": 200, + "word-wrap": "break-word" + } + }, { + name: "rcpt_mime_short", + title: "[Envelope To] To/Cc/Bcc", + breakpoints: "xs sm md", + filterable: false, + classes: "d-none d-xl-table-cell", + style: { + "minWidth": 100, + "maxWidth": 200, + "word-wrap": "break-word" + } + }, { + name: "rcpt_mime", + title: "[Envelope To] To/Cc/Bcc", + breakpoints: "all", + style: {"word-wrap": "break-word"} + }, { + name: "subject", + title: "Subject", + breakpoints: "xs sm md", + style: { + "word-break": "break-all", + "minWidth": 150 + } + }, { + name: "action", + title: "Action", + style: {minwidth: 82} + }, { + name: "passthrough_module", + title: '<div title="The module that has set the pre-result">Pass-through module</div>', + breakpoints: "xs sm md" + }, { + name: "score", + title: "Score", + style: { + "maxWidth": 110, + "text-align": "right", + "white-space": "nowrap" + }, + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "symbols", + title: "Symbols" + + '<div class="sym-order-toggle">' + + '<br><span style="font-weight:normal;">Sort by:</span><br>' + + '<div class="btn-group btn-group-xs btn-sym-order-' + table + '">' + + '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-magnitude">' + + '<input type="radio" class="btn-check" value="magnitude">Magnitude</label>' + + '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-score">' + + '<input type="radio" class="btn-check" value="score">Value</label>' + + '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-name">' + + '<input type="radio" class="btn-check" value="name">Name</label>' + + "</div>" + + "</div>", + breakpoints: "all", + style: {width: 550, maxWidth: 550} + }, { + name: "size", + title: "Msg size", + breakpoints: "xs sm md", + style: {minwidth: 50}, + formatter: ui.formatBytesIEC + }, { + name: "time_real", + title: "Scan time", + breakpoints: "xs sm md", + style: {maxWidth: 72}, + sortValue: function (val) { return Number(val); } + }, { + classes: "history-col-time", + sorted: true, + direction: "DESC", + name: "time", + title: "Time", + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "user", + title: "Authenticated user", + breakpoints: "xs sm md", + style: { + "minWidth": 100, + "maxWidth": 130, + "word-wrap": "break-word" + } + }].filter((col) => { + switch (table) { + case "history": + return (col.name !== "passthrough_module"); + case "scan": + return ["ip", "sender_mime", "rcpt_mime_short", "rcpt_mime", "subject", "size", "user"] + .every((name) => col.name !== name); + default: + return null; + } + }); + }; + + ui.set_page_size = function (table, page_size, changeTablePageSize) { + const n = parseInt(page_size, 10); // HTML Input elements return string representing a number + if (n > 0) { + common.page_size[table] = n; + + if (changeTablePageSize && + $("#historyTable_" + table + " tbody").is(":parent")) { // Table is not empty + clearTimeout(pageSizeTimerId); + const t = FooTable.get("#historyTable_" + table); + if (t) { + pageSizeInvocationCounter = 0; + // Wait for input finish + pageSizeTimerId = setTimeout(() => t.pageSize(n), 1000); + } else if (++pageSizeInvocationCounter < 10) { + // Wait for FooTable instance ready + pageSizeTimerId = setTimeout(() => ui.set_page_size(table, n, true), 1000); + } + } + } + }; + + ui.bindHistoryTableEventHandlers = function (table, symbolsCol) { + function change_symbols_order(order) { + $(".btn-sym-" + table + "-" + order).addClass("active").siblings().removeClass("active"); + const compare_function = get_compare_function(table); + $.each(common.tables[table].rows.all, (i, row) => { + const cell_val = sort_symbols(common.symbols[table][i], compare_function); + row.cells[symbolsCol].val(cell_val, false, true); + }); + } + + $("#selSymOrder_" + table).unbind().change(function () { + const order = this.value; + change_symbols_order(order); + }); + $("#" + table + "_page_size").change((e) => ui.set_page_size(table, e.target.value, true)); + $(document).on("click", ".btn-sym-order-" + table + " input", function () { + const order = this.value; + $("#selSymOrder_" + table).val(order); + change_symbols_order(order); + }); + }; + + ui.destroyTable = function (table) { + if (common.tables[table]) { + common.tables[table].destroy(); + delete common.tables[table]; + } + }; + + ui.initHistoryTable = function (data, items, table, columns, expandFirst) { + /* eslint-disable no-underscore-dangle */ + FooTable.Cell.extend("collapse", function () { + // call the original method + this._super(); + // Copy cell classes to detail row tr element + this._setClasses(this.$detail); + }); + /* eslint-enable no-underscore-dangle */ + + /* eslint-disable consistent-this, no-underscore-dangle, one-var-declaration-per-line */ + FooTable.actionFilter = FooTable.Filtering.extend({ + construct: function (instance) { + this._super(instance); + this.actions = ["reject", "add header", "greylist", + "no action", "soft reject", "rewrite subject"]; + this.def = "Any action"; + this.$action = null; + }, + $create: function () { + this._super(); + const self = this; + const $form_grp = $("<div/>", { + class: "form-group d-inline-flex align-items-center" + }).append($("<label/>", { + class: "sr-only", + text: "Action" + })).prependTo(self.$form); + + $("<div/>", { + class: "form-check form-check-inline", + title: "Invert action match." + }).append( + self.$not = $("<input/>", { + type: "checkbox", + class: "form-check-input", + id: "not_" + table + }).on("change", {self: self}, self._onStatusDropdownChanged), + $("<label/>", { + class: "form-check-label", + for: "not_" + table, + text: "not" + }) + ).appendTo($form_grp); + + self.$action = $("<select/>", { + class: "form-select" + }).on("change", { + self: self + }, self._onStatusDropdownChanged).append( + $("<option/>", { + text: self.def + })).appendTo($form_grp); + + $.each(self.actions, (i, action) => { + self.$action.append($("<option/>").text(action)); + }); + }, + _onStatusDropdownChanged: function (e) { + const {self} = e.data; + const selected = self.$action.val(); + if (selected !== self.def) { + const not = self.$not.is(":checked"); + let query = null; + + if (selected === "reject") { + query = not ? "-reject OR soft" : "reject -soft"; + } else { + query = not ? selected.replace(/(\b\w+\b)/g, "-$1") : selected; + } + + self.addFilter("action", query, ["action"]); + } else { + self.removeFilter("action"); + } + self.filter(); + } + }); + /* eslint-enable consistent-this, no-underscore-dangle, one-var-declaration-per-line */ + + common.tables[table] = FooTable.init("#historyTable_" + table, { + columns: columns, + rows: items, + expandFirst: expandFirst, + paging: { + enabled: true, + limit: 5, + size: common.page_size[table] + }, + filtering: { + enabled: true, + position: "left", + connectors: false + }, + sorting: { + enabled: true + }, + components: { + filtering: FooTable.actionFilter + }, + on: { + "expand.ft.row": function (e, ft, row) { + setTimeout(() => { + const detail_row = row.$el.next(); + const order = common.getSelector("selSymOrder_" + table); + detail_row.find(".btn-sym-" + table + "-" + order) + .addClass("active").siblings().removeClass("active"); + }, 5); + } + } + }); + }; + + ui.preprocess_item = function (item) { + function escape_HTML_array(arr) { + arr.forEach((d, i) => { arr[i] = common.escapeHTML(d); }); + } + + for (const prop in item) { + if (!{}.hasOwnProperty.call(item, prop)) continue; + switch (prop) { + case "rcpt_mime": + case "rcpt_smtp": + escape_HTML_array(item[prop]); + break; + case "symbols": + Object.keys(item.symbols).forEach((key) => { + const sym = item.symbols[key]; + if (!sym.name) { + sym.name = key; + } + sym.name = common.escapeHTML(sym.name); + if (sym.description) { + sym.description = common.escapeHTML(sym.description); + } + + if (sym.options) { + escape_HTML_array(sym.options); + } + }); + break; + default: + if (typeof item[prop] === "string") { + item[prop] = common.escapeHTML(item[prop]); + } + } + } + + if (item.action === "clean" || item.action === "no action") { + item.action = "<div style='font-size:11px' class='badge text-bg-success'>" + item.action + "</div>"; + } else if (item.action === "rewrite subject" || item.action === "add header" || item.action === "probable spam") { + item.action = "<div style='font-size:11px' class='badge text-bg-warning'>" + item.action + "</div>"; + } else if (item.action === "spam" || item.action === "reject") { + item.action = "<div style='font-size:11px' class='badge text-bg-danger'>" + item.action + "</div>"; + } else { + item.action = "<div style='font-size:11px' class='badge text-bg-info'>" + item.action + "</div>"; + } + + const score_content = (item.score < item.required_score) + ? "<span class='text-success'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>" + : "<span class='text-danger'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>"; + + item.score = { + options: { + sortValue: item.score + }, + value: score_content + }; + }; + + ui.unix_time_format = function (tm) { + const date = new Date(tm ? tm * 1000 : 0); + return (common.locale) + ? date.toLocaleString(common.locale) + : date.toLocaleString(); + }; + + ui.process_history_v2 = function (data, table) { + // Display no more than rcpt_lim recipients + const rcpt_lim = 3; + const items = []; + const unsorted_symbols = []; + const compare_function = get_compare_function(table); + + $("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "']").show(); + + $.each(data.rows, + (i, item) => { + function more(p) { + const l = item[p].length; + return (l > rcpt_lim) ? " … (" + l + ")" : ""; + } + function format_rcpt(smtp, mime) { + let full = ""; + let shrt = ""; + if (smtp) { + full = "[" + item.rcpt_smtp.join(", ") + "] "; + shrt = "[" + item.rcpt_smtp.slice(0, rcpt_lim).join(",​") + more("rcpt_smtp") + "]"; + if (mime) { + full += " "; + shrt += " "; + } + } + if (mime) { + full += item.rcpt_mime.join(", "); + shrt += item.rcpt_mime.slice(0, rcpt_lim).join(",​") + more("rcpt_mime"); + } + return {full: full, shrt: shrt}; + } + + function get_symbol_class(name, score) { + if (name.match(/^GREYLIST$/)) { + return "symbol-special"; + } + + if (score < 0) { + return "symbol-negative"; + } else if (score > 0) { + return "symbol-positive"; + } + return null; + } + + ui.preprocess_item(item); + Object.values(item.symbols).forEach((sym) => { + sym.str = '<span class="symbol-default ' + get_symbol_class(sym.name, sym.score) + '"><strong>'; + + if (sym.description) { + sym.str += '<abbr title="' + sym.description + '">' + sym.name + "</abbr>"; + } else { + sym.str += sym.name; + } + sym.str += "</strong> (" + sym.score + ")</span>"; + + if (sym.options) { + sym.str += " [" + sym.options.join(",") + "]"; + } + }); + unsorted_symbols.push(item.symbols); + item.symbols = sort_symbols(item.symbols, compare_function); + if (table === "scan") { + item.unix_time = (new Date()).getTime() / 1000; + } + item.time = { + value: ui.unix_time_format(item.unix_time), + options: { + sortValue: item.unix_time + } + }; + item.time_real = item.time_real.toFixed(3); + item.id = item["message-id"]; + + if (table === "history") { + let rcpt = {}; + if (!item.rcpt_mime.length) { + rcpt = format_rcpt(true, false); + } else if ( + $(item.rcpt_mime).not(item.rcpt_smtp).length !== 0 || + $(item.rcpt_smtp).not(item.rcpt_mime).length !== 0 + ) { + rcpt = format_rcpt(true, true); + } else { + rcpt = format_rcpt(false, true); + } + item.rcpt_mime_short = rcpt.shrt; + item.rcpt_mime = rcpt.full; + + if (item.sender_mime !== item.sender_smtp) { + item.sender_mime = "[" + item.sender_smtp + "] " + item.sender_mime; + } + } + items.push(item); + }); + + return {items: items, symbols: unsorted_symbols}; + }; + + ui.waitForRowsDisplayed = function (table, rows_total, callback, iteration) { + let i = (typeof iteration === "undefined") ? 10 : iteration; + const num_rows = $("#historyTable_" + table + " > tbody > tr:not(.footable-detail-row)").length; + if (num_rows === common.page_size[table] || + num_rows === rows_total) { + return callback(); + } else if (--i) { + setTimeout(() => { + ui.waitForRowsDisplayed(table, rows_total, callback, i); + }, 500); + } + return null; + }; + + return ui; + }); diff --git a/interface/js/app/rspamd.js b/interface/js/app/rspamd.js new file mode 100644 index 0000000..938f048 --- /dev/null +++ b/interface/js/app/rspamd.js @@ -0,0 +1,486 @@ +/* + The MIT License (MIT) + + Copyright (C) 2012-2013 Anton Simonov <untone@gmail.com> + Copyright (C) 2014-2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global require, Visibility */ + +define(["jquery", "app/common", "stickytabs", "visibility", + "bootstrap", "fontawesome"], +($, common) => { + "use strict"; + const ui = {}; + + const defaultAjaxTimeout = 20000; + + const ajaxTimeoutBox = ".popover #settings-popover #ajax-timeout"; + const graphs = {}; + let checked_server = "All SERVERS"; + const timer_id = []; + + function ajaxSetup(ajax_timeout, setFieldValue, saveToLocalStorage) { + const timeout = (ajax_timeout && ajax_timeout >= 0) ? ajax_timeout : defaultAjaxTimeout; + if (saveToLocalStorage) localStorage.setItem("ajax_timeout", timeout); + if (setFieldValue) $(ajaxTimeoutBox).val(timeout); + + $.ajaxSetup({ + timeout: timeout, + jsonp: false + }); + } + + function cleanCredentials() { + sessionStorage.clear(); + $("#statWidgets").empty(); + $("#listMaps").empty(); + $("#modalBody").empty(); + } + + function stopTimers() { + for (const key in timer_id) { + if (!{}.hasOwnProperty.call(timer_id, key)) continue; + Visibility.stop(timer_id[key]); + } + } + + function disconnect() { + [graphs, common.tables].forEach((o) => { + Object.keys(o).forEach((key) => { + o[key].destroy(); + delete o[key]; + }); + }); + + // Remove jquery-stickytabs listeners + $(window).off("hashchange"); + $(".nav-tabs-sticky > .nav-item > .nav-link").off("click").removeClass("active"); + + stopTimers(); + cleanCredentials(); + ui.connect(); + } + + function tabClick(id) { + let tab_id = id; + if ($(id).attr("disabled")) return; + let navBarControls = $("#selSrv, #navBar li, #navBar a, #navBar button"); + if (id !== "#autoRefresh") navBarControls.attr("disabled", true).addClass("disabled", true); + + stopTimers(); + + if (id === "#refresh" || id === "#autoRefresh") { + tab_id = "#" + $(".nav-link.active").attr("id"); + } + + $("#autoRefresh").hide(); + $("#refresh").addClass("radius-right"); + + function setAutoRefresh(refreshInterval, timer, callback) { + function countdown(interval) { + Visibility.stop(timer_id.countdown); + if (!interval) { + $("#countdown").text("--:--"); + return; + } + + let timeLeft = interval; + $("#countdown").text("00:00"); + timer_id.countdown = Visibility.every(1000, 1000, () => { + timeLeft -= 1000; + $("#countdown").text(new Date(timeLeft).toISOString().substr(14, 5)); + if (timeLeft <= 0) Visibility.stop(timer_id.countdown); + }); + } + + $("#refresh").removeClass("radius-right"); + $("#autoRefresh").show(); + + countdown(refreshInterval); + if (!refreshInterval) return; + timer_id[timer] = Visibility.every(refreshInterval, () => { + countdown(refreshInterval); + if ($("#refresh").attr("disabled")) return; + $("#refresh").attr("disabled", true).addClass("disabled", true); + callback(); + }); + } + + if (["#scan_nav", "#selectors_nav", "#disconnect"].indexOf(tab_id) !== -1) { + $("#refresh").hide(); + } else { + $("#refresh").show(); + } + + switch (tab_id) { + case "#status_nav": + require(["app/stats"], (module) => { + const refreshInterval = $(".dropdown-menu a.active.preset").data("value"); + setAutoRefresh(refreshInterval, "status", + () => module.statWidgets(graphs, checked_server)); + if (id !== "#autoRefresh") module.statWidgets(graphs, checked_server); + + $(".preset").show(); + $(".history").hide(); + $(".dynamic").hide(); + }); + break; + case "#throughput_nav": + require(["app/graph"], (module) => { + const selData = common.getSelector("selData"); // Graph's dataset selector state + const step = { + day: 60000, + week: 300000 + }; + let refreshInterval = step[selData] || 3600000; + $("#dynamic-item").text((refreshInterval / 60000) + " min"); + + if (!$(".dropdown-menu a.active.dynamic").data("value")) { + refreshInterval = null; + } + setAutoRefresh(refreshInterval, "throughput", + () => module.draw(graphs, common.neighbours, checked_server, selData)); + if (id !== "#autoRefresh") module.draw(graphs, common.neighbours, checked_server, selData); + + $(".preset").hide(); + $(".history").hide(); + $(".dynamic").show(); + }); + break; + case "#configuration_nav": + require(["app/config"], (module) => { + module.getActions(checked_server); + module.getMaps(checked_server); + }); + break; + case "#symbols_nav": + require(["app/symbols"], (module) => module.getSymbols(checked_server)); + break; + case "#scan_nav": + require(["app/upload"]); + break; + case "#selectors_nav": + require(["app/selectors"], (module) => module.displayUI()); + break; + case "#history_nav": + require(["app/history"], (module) => { + function getHistoryAndErrors() { + module.getHistory(); + module.getErrors(); + } + const refreshInterval = $(".dropdown-menu a.active.history").data("value"); + setAutoRefresh(refreshInterval, "history", + () => getHistoryAndErrors()); + if (id !== "#autoRefresh") getHistoryAndErrors(); + + $(".preset").hide(); + $(".history").show(); + $(".dynamic").hide(); + }); + break; + case "#disconnect": + disconnect(); + break; + default: + } + + setTimeout(() => { + // Do not enable Refresh button until AJAX requests to all neighbours are finished + if (tab_id === "#history_nav") navBarControls = $(navBarControls).not("#refresh"); + + navBarControls.removeAttr("disabled").removeClass("disabled"); + }, (id === "#autoRefresh") ? 0 : 1000); + } + + function saveCredentials(password) { + sessionStorage.setItem("Password", password); + } + + function displayUI() { + // In many browsers local storage can only store string. + // So when we store the boolean true or false, it actually stores the strings "true" or "false". + common.read_only = sessionStorage.getItem("read_only") === "true"; + + common.query("auth", { + success: function (neighbours_status) { + $("#selSrv").empty(); + $("#selSrv").append($('<option value="All SERVERS">All SERVERS</option>')); + neighbours_status.forEach((e) => { + $("#selSrv").append($('<option value="' + e.name + '">' + e.name + "</option>")); + if (checked_server === e.name) { + $('#selSrv [value="' + e.name + '"]').prop("selected", true); + } else if (!e.status) { + $('#selSrv [value="' + e.name + '"]').prop("disabled", true); + } + }); + }, + complete: function () { + ajaxSetup(localStorage.getItem("ajax_timeout")); + + if (common.read_only) { + $(".ro-disable").attr("disabled", true); + $(".ro-hide").hide(); + } else { + $(".ro-disable").removeAttr("disabled", true); + $(".ro-hide").show(); + } + + $("#preloader").addClass("d-none"); + $("#navBar, #mainUI").removeClass("d-none"); + $(".nav-tabs-sticky").stickyTabs({initialTab: "#status_nav"}); + }, + errorMessage: "Cannot get server status", + server: "All SERVERS" + }); + } + + + // Public functions + + ui.connect = function () { + // Prevent locking out of the WebUI if timeout is too low. + let timeout = localStorage.getItem("ajax_timeout"); + if (timeout < defaultAjaxTimeout) timeout = defaultAjaxTimeout; + ajaxSetup(timeout); + + // Query "/stat" to check if user is already logged in or client ip matches "secure_ip" + $.ajax({ + type: "GET", + url: "stat", + success: function (data) { + sessionStorage.setItem("read_only", data.read_only); + displayUI(); + }, + error: function () { + function clearFeedback() { + $("#connectPassword").off("input").removeClass("is-invalid"); + $("#authInvalidCharFeedback,#authUnauthorizedFeedback").hide(); + } + + $("#connectDialog") + .on("show.bs.modal", () => { + $("#connectDialog").off("show.bs.modal"); + clearFeedback(); + }) + .on("shown.bs.modal", () => { + $("#connectDialog").off("shown.bs.modal"); + $("#connectPassword").focus(); + }) + .modal("show"); + + $("#connectForm").off("submit").on("submit", (e) => { + e.preventDefault(); + const password = $("#connectPassword").val(); + + function invalidFeedback(tooltip) { + $("#connectPassword") + .addClass("is-invalid") + .off("input").on("input", () => clearFeedback()); + $(tooltip).show(); + } + + if (!(/^[\u0020-\u007e]*$/).test(password)) { + invalidFeedback("#authInvalidCharFeedback"); + $("#connectPassword").focus(); + return; + } + + common.query("auth", { + headers: { + Password: password + }, + success: function (json) { + const [{data}] = json; + $("#connectPassword").val(""); + if (data.auth === "ok") { + sessionStorage.setItem("read_only", data.read_only); + saveCredentials(password); + $("#connectForm").off("submit"); + $("#connectDialog").modal("hide"); + displayUI(); + } + }, + error: function (jqXHR, textStatus) { + if (textStatus.statusText === "Unauthorized") { + invalidFeedback("#authUnauthorizedFeedback"); + } else { + common.alertMessage("alert-modal alert-error", textStatus.statusText); + } + $("#connectPassword").val(""); + $("#connectPassword").focus(); + }, + params: { + global: false, + }, + server: "local" + }); + }); + } + }); + }; + + + (function initSettings() { + let selected_locale = null; + let custom_locale = null; + const localeTextbox = ".popover #settings-popover #locale"; + + function validateLocale(saveToLocalStorage) { + function toggle_form_group_class(remove, add) { + $(localeTextbox).removeClass("is-" + remove).addClass("is-" + add); + } + + const now = new Date(); + + if (custom_locale.length) { + try { + now.toLocaleString(custom_locale); + + if (saveToLocalStorage) localStorage.setItem("custom_locale", custom_locale); + common.locale = (selected_locale === "custom") ? custom_locale : null; + toggle_form_group_class("invalid", "valid"); + } catch (err) { + common.locale = null; + toggle_form_group_class("valid", "invalid"); + } + } else { + if (saveToLocalStorage) localStorage.setItem("custom_locale", null); + common.locale = null; + $(localeTextbox).removeClass("is-valid is-invalid"); + } + + // Display date example + $(".popover #settings-popover #date-example").text( + (common.locale) + ? now.toLocaleString(common.locale) + : now.toLocaleString() + ); + } + + $("#settings").popover({ + container: "body", + placement: "bottom", + html: true, + sanitize: false, + content: function () { + // Using .clone() has the side-effect of producing elements with duplicate id attributes. + return $("#settings-popover").clone(); + } + // Restore the tooltip of the element that the popover is attached to. + }).attr("title", function () { + return $(this).attr("data-original-title"); + }); + $("#settings").on("click", (e) => { + e.preventDefault(); + }); + $("#settings").on("inserted.bs.popover", () => { + selected_locale = localStorage.getItem("selected_locale") || "browser"; + custom_locale = localStorage.getItem("custom_locale") || ""; + validateLocale(); + + $('.popover #settings-popover input:radio[name="locale"]').val([selected_locale]); + $(localeTextbox).val(custom_locale); + + ajaxSetup(localStorage.getItem("ajax_timeout"), true); + }); + $(document).on("change", '.popover #settings-popover input:radio[name="locale"]', function () { + selected_locale = this.value; + localStorage.setItem("selected_locale", selected_locale); + validateLocale(); + }); + $(document).on("input", localeTextbox, () => { + custom_locale = $(localeTextbox).val(); + validateLocale(true); + }); + $(document).on("input", ajaxTimeoutBox, () => { + ajaxSetup($(ajaxTimeoutBox).val(), false, true); + }); + $(document).on("click", ".popover #settings-popover #ajax-timeout-restore", () => { + ajaxSetup(null, true, true); + }); + + // Dismiss Bootstrap popover by clicking outside + $("body").on("click", (e) => { + $(".popover").each(function () { + if ( + // Popover's descendant + $(this).has(e.target).length || + // Button (or icon within a button) that triggers the popover. + $(e.target).closest("button").attr("aria-describedby") === this.id + ) return; + $("#settings").popover("hide"); + }); + }); + }()); + + $("#selData").change(() => { + tabClick("#throughput_nav"); + }); + + $(document).ajaxStart(() => { + $("#refresh > svg").addClass("fa-spin"); + }); + $(document).ajaxComplete(() => { + setTimeout(() => { + $("#refresh > svg").removeClass("fa-spin"); + }, 1000); + }); + + $('a[data-bs-toggle="tab"]').on("shown.bs.tab", function () { + tabClick("#" + $(this).attr("id")); + }); + $("#refresh, #disconnect").on("click", function (e) { + e.preventDefault(); + tabClick("#" + $(this).attr("id")); + }); + $(".dropdown-menu a").click(function (e) { + e.preventDefault(); + const classList = $(this).attr("class"); + const [menuClass] = (/\b(?:dynamic|history|preset)\b/).exec(classList); + $(".dropdown-menu a.active." + menuClass).removeClass("active"); + $(this).addClass("active"); + tabClick("#autoRefresh"); + }); + + $("#selSrv").change(function () { + checked_server = this.value; + $("#selSrv [value=\"" + checked_server + "\"]").prop("checked", true); + if (checked_server === "All SERVERS") { + $("#learnServers").show(); + } else { + $("#learnServers").hide(); + } + tabClick("#" + $("#tablist > .nav-item > .nav-link.active").attr("id")); + }); + + // Radio buttons + $(document).on("click", "input:radio[name=\"clusterName\"]", function () { + if (!this.disabled) { + checked_server = this.value; + tabClick("#status_nav"); + } + }); + + $("#loading").addClass("d-none"); + + return ui; +}); diff --git a/interface/js/app/selectors.js b/interface/js/app/selectors.js new file mode 100644 index 0000000..53240d8 --- /dev/null +++ b/interface/js/app/selectors.js @@ -0,0 +1,145 @@ +define(["jquery", "app/common"], + ($, common) => { + "use strict"; + const ui = {}; + + function enable_disable_check_btn() { + $("#selectorsChkMsgBtn").prop("disabled", ( + $.trim($("#selectorsMsgArea").val()).length === 0 || + !$("#selectorsSelArea").hasClass("is-valid") + )); + } + + function get_server() { + const checked_server = common.getSelector("selSrv"); + return (checked_server === "All SERVERS") ? "local" : checked_server; + } + + function checkMsg(data) { + const selector = $("#selectorsSelArea").val(); + common.query("plugins/selectors/check_message?selector=" + encodeURIComponent(selector), { + data: data, + method: "POST", + success: function (neighbours_status) { + const json = neighbours_status[0].data; + if (json.success) { + common.alertMessage("alert-success", "Message successfully processed"); + $("#selectorsResArea") + .val(Object.prototype.hasOwnProperty.call(json, "data") ? json.data.toString() : ""); + } else { + common.alertMessage("alert-error", "Unexpected error processing message"); + } + }, + server: get_server() + }); + } + + function checkSelectors() { + function toggle_form_group_class(remove, add) { + $("#selectorsSelArea").removeClass("is-" + remove).addClass("is-" + add); + enable_disable_check_btn(); + } + const selector = $("#selectorsSelArea").val(); + if (selector.length && !common.read_only) { + common.query("plugins/selectors/check_selector?selector=" + encodeURIComponent(selector), { + method: "GET", + success: function (json) { + if (json[0].data.success) { + toggle_form_group_class("invalid", "valid"); + } else { + toggle_form_group_class("valid", "invalid"); + } + }, + server: get_server() + }); + } else { + $("#selectorsSelArea").removeClass("is-valid is-invalid"); + enable_disable_check_btn(); + } + } + + function buildLists() { + function build_table_from_json(json, table_id) { + Object.keys(json).forEach((key) => { + const td = $("<td/>"); + const tr = $("<tr/>") + .append(td.clone().html("<code>" + key + "</code>")) + .append(td.clone().html(json[key].description)); + $(table_id + " tbody").append(tr); + }); + } + + function getList(list) { + common.query("plugins/selectors/list_" + list, { + method: "GET", + success: function (neighbours_status) { + const json = neighbours_status[0].data; + build_table_from_json(json, "#selectorsTable-" + list); + }, + server: get_server() + }); + } + + getList("extractors"); + getList("transforms"); + } + + ui.displayUI = function () { + if (!common.read_only && + !$("#selectorsTable-extractors>tbody>tr").length && + !$("#selectorsTable-transforms>tbody>tr").length) buildLists(); + if (!$("#selectorsSelArea").is(".is-valid, .is-invalid")) checkSelectors(); + }; + + + function toggleSidebar(side) { + $("#sidebar-" + side).toggleClass("collapsed"); + let contentClass = "col-lg-6"; + const openSidebarsCount = $("#sidebar-left").hasClass("collapsed") + + $("#sidebar-right").hasClass("collapsed"); + switch (openSidebarsCount) { + case 1: + contentClass = "col-lg-9"; + break; + case 2: + contentClass = "col-lg-12"; + break; + default: + } + $("#content").removeClass("col-lg-12 col-lg-9 col-lg-6") + .addClass(contentClass); + } + $("#sidebar-tab-left>a").click(() => { + toggleSidebar("left"); + return false; + }); + $("#sidebar-tab-right>a").click(() => { + toggleSidebar("right"); + return false; + }); + + $("#selectorsMsgClean").on("click", () => { + $("#selectorsChkMsgBtn").attr("disabled", true); + $("#selectorsMsgArea").val(""); + return false; + }); + $("#selectorsClean").on("click", () => { + $("#selectorsSelArea").val(""); + checkSelectors(); + return false; + }); + $("#selectorsChkMsgBtn").on("click", () => { + $("#selectorsResArea").val(""); + checkMsg($("#selectorsMsgArea").val()); + return false; + }); + + $("#selectorsMsgArea").on("input", () => { + enable_disable_check_btn(); + }); + $("#selectorsSelArea").on("input", () => { + checkSelectors(); + }); + + return ui; + }); diff --git a/interface/js/app/stats.js b/interface/js/app/stats.js new file mode 100644 index 0000000..04b4a75 --- /dev/null +++ b/interface/js/app/stats.js @@ -0,0 +1,372 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +define(["jquery", "app/common", "d3pie", "d3"], + ($, common, D3Pie, d3) => { + "use strict"; + // @ ms to date + function msToTime(seconds) { + if (!Number.isFinite(seconds)) return "???"; + /* eslint-disable no-bitwise */ + const years = seconds / 31536000 >> 0; // 3600*24*365 + const months = seconds % 31536000 / 2628000 >> 0; // 3600*24*365/12 + const days = seconds % 31536000 % 2628000 / 86400 >> 0; // 24*3600 + const hours = seconds % 31536000 % 2628000 % 86400 / 3600 >> 0; + const minutes = seconds % 31536000 % 2628000 % 86400 % 3600 / 60 >> 0; + /* eslint-enable no-bitwise */ + let out = null; + if (years > 0) { + if (months > 0) { + out = years + "yr " + months + "mth"; + } else { + out = years + "yr " + days + "d"; + } + } else if (months > 0) { + out = months + "mth " + days + "d"; + } else if (days > 0) { + out = days + "d " + hours + "hr"; + } else if (hours > 0) { + out = hours + "hr " + minutes + "min"; + } else { + out = minutes + "min"; + } + return out; + } + + function displayStatWidgets(checked_server) { + const servers = JSON.parse(sessionStorage.getItem("Credentials")); + let data = {}; + if (servers && servers[checked_server]) { + ({data} = servers[checked_server]); + } + + const stat_w = []; + $("#statWidgets").empty().hide(); + $.each(data, (i, item) => { + const widgetsOrder = ["scanned", "no action", "greylist", "add header", "rewrite subject", "reject", "learned"]; + + function widget(k, v, cls) { + const c = (typeof cls === "undefined") ? "" : cls; + const titleAtt = d3.format(",")(v) + " " + k; + return '<div class="card stat-box d-inline-block text-center shadow-sm me-3 px-3">' + + '<div class="widget overflow-hidden p-2' + c + '" title="' + titleAtt + + '"><strong class="d-block mt-2 mb-1 fw-bold">' + + d3.format(".3~s")(v) + "</strong>" + k + "</div></div>"; + } + + if (i === "auth" || i === "error") return; // Skip to the next iteration + if (i === "uptime" || i === "version") { + let cls = "border-end "; + let val = item; + if (i === "uptime") { + cls = ""; + val = msToTime(item); + } + $('<div class="' + cls + 'float-start px-3"><strong class="d-block mt-2 mb-1 fw-bold">' + + val + "</strong>" + i + "</div>") + .appendTo("#statWidgets"); + } else if (i === "actions") { + $.each(item, (action, count) => { + stat_w[widgetsOrder.indexOf(action)] = widget(action, count); + }); + } else { + stat_w[widgetsOrder.indexOf(i)] = widget(i, item, " text-capitalize"); + } + }); + $.each(stat_w, (i, item) => { + $(item).appendTo("#statWidgets"); + }); + $("#statWidgets > div:not(.stat-box)") + .wrapAll('<div class="card stat-box text-center shadow-sm float-end">' + + '<div class="widget overflow-hidden p-2 text-capitalize"></div></div>'); + $("#statWidgets").find("div.float-end").appendTo("#statWidgets"); + $("#statWidgets").show(); + + $("#clusterTable tbody").empty(); + $("#selSrv").empty(); + $.each(servers, (key, val) => { + let row_class = "danger"; + let glyph_status = "fas fa-times"; + let version = "???"; + let uptime = "???"; + let short_id = "???"; + let scan_times = { + data: "???", + title: "" + }; + if (val.status) { + row_class = "success"; + glyph_status = "fas fa-check"; + if (Number.isFinite(val.data.uptime)) { + uptime = msToTime(val.data.uptime); + } + if ("version" in val.data) { + ({version} = val.data); + } + if (key === "All SERVERS") { + short_id = ""; + scan_times.data = ""; + } else { + if ("config_id" in val.data) { + short_id = val.data.config_id.substring(0, 8); + } + if ("scan_times" in val.data) { + const [min, max] = d3.extent(val.data.scan_times); + if (max) { + const f = d3.format(".3f"); + scan_times = { + data: "<small>" + f(min) + "/</small>" + + f(d3.mean(val.data.scan_times)) + + "<small>/" + f(max) + "</small>", + title: ' title="min/avg/max"' + }; + } else { + scan_times = { + data: "-", + title: ' title="Have not scanned anything yet"' + }; + } + } + } + } + + $("#clusterTable tbody").append('<tr class="' + row_class + '">' + + '<td class="align-middle"><input type="radio" class="form-check m-auto" name="clusterName" value="' + + key + '"></td>' + + "<td>" + key + "</td>" + + "<td>" + val.host + "</td>" + + '<td class="text-center"><span class="icon"><i class="' + glyph_status + '"></i></span></td>' + + '<td class="text-center"' + scan_times.title + ">" + scan_times.data + "</td>" + + '<td class="text-end' + + ((Number.isFinite(val.data.uptime) && val.data.uptime < 3600) + ? ' warning" title="Has been restarted within the last hour"' + : "") + + '">' + uptime + "</td>" + + "<td>" + version + "</td>" + + "<td>" + short_id + "</td></tr>"); + + $("#selSrv").append($('<option value="' + key + '">' + key + "</option>")); + + if (checked_server === key) { + $('#clusterTable tbody [value="' + key + '"]').prop("checked", true); + $('#selSrv [value="' + key + '"]').prop("selected", true); + } else if (!val.status) { + $('#clusterTable tbody [value="' + key + '"]').prop("disabled", true); + $('#selSrv [value="' + key + '"]').prop("disabled", true); + } + }); + + function addStatfiles(server, statfiles) { + $.each(statfiles, (i, statfile) => { + let cls = ""; + switch (statfile.symbol) { + case "BAYES_SPAM": + cls = "symbol-positive"; + break; + case "BAYES_HAM": + cls = "symbol-negative"; + break; + default: + } + $("#bayesTable tbody").append("<tr>" + + (i === 0 ? '<td rowspan="' + statfiles.length + '">' + server + "</td>" : "") + + '<td class="' + cls + '">' + statfile.symbol + "</td>" + + '<td class="' + cls + '">' + statfile.type + "</td>" + + '<td class="text-end ' + cls + '">' + statfile.revision + "</td>" + + '<td class="text-end ' + cls + '">' + statfile.users + "</td></tr>"); + }); + } + + function addFuzzyStorage(server, storages) { + let i = 0; + $.each(storages, (storage, hashes) => { + $("#fuzzyTable tbody").append("<tr>" + + (i === 0 ? '<td rowspan="' + Object.keys(storages).length + '">' + server + "</td>" : "") + + "<td>" + storage + "</td>" + + '<td class="text-end">' + hashes + "</td></tr>"); + i++; + }); + } + + $("#bayesTable tbody, #fuzzyTable tbody").empty(); + if (checked_server === "All SERVERS") { + $.each(servers, (server, val) => { + if (server !== "All SERVERS") { + addStatfiles(server, val.data.statfiles); + addFuzzyStorage(server, val.data.fuzzy_hashes); + } + }); + } else { + addStatfiles(checked_server, data.statfiles); + addFuzzyStorage(checked_server, data.fuzzy_hashes); + } + } + + function getChart(graphs, checked_server) { + if (!graphs.chart) { + graphs.chart = new D3Pie("chart", { + labels: { + inner: { + offset: 0 + }, + outer: { + collideHeight: 18, + } + }, + size: { + pieInnerRadius: "50%" + }, + title: "Rspamd filter stats", + total: { + enabled: true, + label: "Scanned" + } + }); + } + + const data = []; + const creds = JSON.parse(sessionStorage.getItem("Credentials")); + // Controller doesn't return the 'actions' object until at least one message is scanned + if (creds && creds[checked_server] && creds[checked_server].data.scanned) { + const {actions} = creds[checked_server].data; + + ["no action", "soft reject", "add header", "rewrite subject", "greylist", "reject"] + .forEach((action) => { + data.push({ + color: common.chartLegend.find((item) => item.label === action).color, + label: action, + value: actions[action] + }); + }); + } + graphs.chart.data(data); + } + + // Public API + const ui = { + statWidgets: function (graphs, checked_server) { + common.query("stat", { + success: function (neighbours_status) { + const neighbours_sum = { + version: neighbours_status[0].data.version, + uptime: 0, + scanned: 0, + learned: 0, + actions: { + "no action": 0, + "add header": 0, + "rewrite subject": 0, + "greylist": 0, + "reject": 0, + "soft reject": 0, + } + }; + let status_count = 0; + const promises = []; + const to_Credentials = { + "All SERVERS": { + name: "All SERVERS", + url: "", + host: "", + checked: true, + status: true + } + }; + + function process_node_stat(e) { + const {data} = neighbours_status[e]; + // Controller doesn't return the 'actions' object until at least one message is scanned + if (data.scanned) { + for (const action in neighbours_sum.actions) { + if ({}.hasOwnProperty.call(neighbours_sum.actions, action)) { + neighbours_sum.actions[action] += data.actions[action]; + } + } + } + ["learned", "scanned", "uptime"].forEach((p) => { + neighbours_sum[p] += data[p]; + }); + status_count++; + } + + // Get config_id, version and uptime using /auth query for Rspamd 2.5 and earlier + function get_legacy_stat(e) { + const alerted = "alerted_stats_legacy_" + neighbours_status[e].name; + promises.push($.ajax({ + url: neighbours_status[e].url + "auth", + headers: {Password: common.getPassword()}, + success: function (data) { + sessionStorage.removeItem(alerted); + ["config_id", "version", "uptime"].forEach((p) => { + neighbours_status[e].data[p] = data[p]; + }); + process_node_stat(e); + }, + error: function (jqXHR, textStatus, errorThrown) { + if (!(alerted in sessionStorage)) { + sessionStorage.setItem(alerted, true); + common.alertMessage("alert-error", neighbours_status[e].name + " > " + + "Cannot receive legacy stats data" + (errorThrown ? ": " + errorThrown : "")); + } + process_node_stat(e); + } + })); + } + + for (const e in neighbours_status) { + if ({}.hasOwnProperty.call(neighbours_status, e)) { + to_Credentials[neighbours_status[e].name] = neighbours_status[e]; + if (neighbours_status[e].status === true) { + // Remove alert status + sessionStorage.removeItem("alerted_stats_" + neighbours_status[e].name); + + if ({}.hasOwnProperty.call(neighbours_status[e].data, "version")) { + process_node_stat(e); + } else { + get_legacy_stat(e); + } + } + } + } + setTimeout(() => { + $.when.apply($, promises).always(() => { + neighbours_sum.uptime = Math.floor(neighbours_sum.uptime / status_count); + to_Credentials["All SERVERS"].data = neighbours_sum; + sessionStorage.setItem("Credentials", JSON.stringify(to_Credentials)); + displayStatWidgets(checked_server); + getChart(graphs, checked_server); + }); + }, promises.length ? 100 : 0); + }, + complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + errorMessage: "Cannot receive stats data", + errorOnceId: "alerted_stats_", + server: "All SERVERS" + }); + }, + }; + + return ui; + } +); diff --git a/interface/js/app/symbols.js b/interface/js/app/symbols.js new file mode 100644 index 0000000..1e3fb5d --- /dev/null +++ b/interface/js/app/symbols.js @@ -0,0 +1,260 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global FooTable */ + +define(["jquery", "app/common", "footable"], + ($, common) => { + "use strict"; + const ui = {}; + let altered = {}; + + function clear_altered() { + $("#save-alert").addClass("d-none"); + altered = {}; + } + + function saveSymbols(server) { + $("#save-alert button").attr("disabled", true); + + const values = []; + Object.entries(altered).forEach(([key, value]) => values.push({name: key, value: value})); + + common.query("./savesymbols", { + success: function () { + clear_altered(); + common.alertMessage("alert-modal alert-success", "Symbols successfully saved"); + }, + complete: () => $("#save-alert button").removeAttr("disabled", true), + errorMessage: "Save symbols error", + method: "POST", + params: { + data: JSON.stringify(values), + dataType: "json", + }, + server: server + }); + } + + function process_symbols_data(data) { + const items = []; + const lookup = {}; + const freqs = []; + const distinct_groups = []; + + data.forEach((group) => { + group.rules.forEach((item) => { + const formatter = new Intl.NumberFormat("en", { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + useGrouping: false + }); + item.group = group.group; + let label_class = ""; + if (item.weight < 0) { + label_class = "scorebar-ham"; + } else if (item.weight > 0) { + label_class = "scorebar-spam"; + } + item.weight = '<input class="form-control input-sm mb-disabled scorebar ' + label_class + + '" autocomplete="off" type="number" step="0.01" tabindex="1" ' + + 'value="' + formatter.format(item.weight) + '" id="_sym_' + item.symbol + '"></input>'; + if (!item.time) { + item.time = 0; + } + item.time = Number(item.time).toFixed(2) + "s"; + if (!item.frequency) { + item.frequency = 0; + } + freqs.push(item.frequency); + item.frequency = Number(item.frequency).toFixed(2); + if (!(item.group in lookup)) { + lookup[item.group] = 1; + distinct_groups.push(item.group); + } + items.push(item); + }); + }); + + // For better mean calculations + const avg_freq = freqs + .sort((a, b) => Number(a) < Number(b)) + .reduce((f1, acc) => f1 + acc) / (freqs.length !== 0 ? freqs.length : 1.0); + let mult = 1.0; + let exp = 0.0; + + if (avg_freq > 0.0) { + while (mult * avg_freq < 1.0) { + mult *= 10; + exp++; + } + } + $.each(items, (i, item) => { + item.frequency = Number(item.frequency) * mult; + + if (exp > 0) { + item.frequency = item.frequency.toFixed(2) + "e-" + exp; + } else { + item.frequency = item.frequency.toFixed(2); + } + }); + return [items, distinct_groups]; + } + // @get symbols into modal form + ui.getSymbols = function (checked_server) { + clear_altered(); + common.query("symbols", { + success: function (json) { + const [{data}] = json; + const items = process_symbols_data(data); + + /* eslint-disable consistent-this, no-underscore-dangle, one-var-declaration-per-line */ + FooTable.groupFilter = FooTable.Filtering.extend({ + construct: function (instance) { + this._super(instance); + [,this.groups] = items; + this.def = "Any group"; + this.$group = null; + }, + $create: function () { + this._super(); + const self = this; + const $form_grp = $("<div/>", { + class: "form-group" + }).append($("<label/>", { + class: "sr-only", + text: "Group" + })).prependTo(self.$form); + + self.$group = $("<select/>", { + class: "form-select" + }).on("change", { + self: self + }, self._onStatusDropdownChanged).append( + $("<option/>", { + text: self.def + })).appendTo($form_grp); + + $.each(self.groups, (i, group) => { + self.$group.append($("<option/>").text(group)); + }); + }, + _onStatusDropdownChanged: function (e) { + const {self} = e.data; + const selected = $(this).val(); + if (selected !== self.def) { + self.addFilter("group", selected, ["group"]); + } else { + self.removeFilter("group"); + } + self.filter(); + }, + draw: function () { + this._super(); + const group = this.find("group"); + if (group instanceof FooTable.Filter) { + this.$group.val(group.query.val()); + } else { + this.$group.val(this.def); + } + } + }); + /* eslint-enable consistent-this, no-underscore-dangle, one-var-declaration-per-line */ + + common.tables.symbols = FooTable.init("#symbolsTable", { + columns: [ + {sorted: true, direction: "ASC", name: "group", title: "Group"}, + {name: "symbol", title: "Symbol"}, + {name: "description", title: "Description", breakpoints: "xs sm"}, + {name: "weight", title: "Score"}, + {name: "frequency", + title: "Frequency", + breakpoints: "xs sm", + sortValue: function (value) { return Number(value).toFixed(2); }}, + {name: "time", title: "Avg. time", breakpoints: "xs sm"}, + ], + rows: items[0], + paging: { + enabled: true, + limit: 5, + size: 25 + }, + filtering: { + enabled: true, + position: "left", + connectors: false + }, + sorting: { + enabled: true + }, + components: { + filtering: FooTable.groupFilter + }, + on: { + "ready.ft.table": function () { + if (common.read_only) { + $(".mb-disabled").attr("disabled", true); + } + } + } + }); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }; + + + $("#updateSymbols").on("click", (e) => { + e.preventDefault(); + clear_altered(); + const checked_server = common.getSelector("selSrv"); + common.query("symbols", { + success: function (data) { + const [items] = process_symbols_data(data[0].data); + common.tables.symbols.rows.load(items); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }); + + $("#symbolsTable") + .on("input", ".scorebar", ({target}) => { + const t = $(target); + t.removeClass("scorebar-ham scorebar-spam"); + if (target.value < 0) { + t.addClass("scorebar-ham"); + } else if (target.value > 0) { + t.addClass("scorebar-spam"); + } + }) + .on("change", ".scorebar", ({target}) => { + altered[$(target).attr("id").substring(5)] = parseFloat(target.value); + $("#save-alert").removeClass("d-none"); + }); + + $("#save-alert button") + .on("click", ({target}) => saveSymbols($(target).data("save"))); + + return ui; + }); diff --git a/interface/js/app/upload.js b/interface/js/app/upload.js new file mode 100644 index 0000000..546f5cc --- /dev/null +++ b/interface/js/app/upload.js @@ -0,0 +1,243 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global require */ + +define(["jquery", "app/common", "app/libft"], + ($, common, libft) => { + "use strict"; + const ui = {}; + + function cleanTextUpload(source) { + $("#" + source + "TextSource").val(""); + } + + // @upload text + 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 server() { + if (common.getSelector("selSrv") === "All SERVERS" && + common.getSelector("selLearnServers") === "random") { + const servers = $("#selSrv option").slice(1).map((_, o) => o.value); + return servers[Math.floor(Math.random() * servers.length)]; + } + return null; + } + + common.query(url, { + data: data, + params: { + processData: false, + }, + method: "POST", + 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); + } + }, + server: server() + }); + } + + function get_server() { + const checked_server = common.getSelector("selSrv"); + return (checked_server === "All SERVERS") ? "local" : checked_server; + } + + // @upload text + function scanText(data, headers) { + common.query("checkv2", { + data: data, + params: { + processData: false, + }, + method: "POST", + headers: headers, + success: function (neighbours_status) { + function scrollTop(rows_total) { + // Is there a way to get an event when all rows are loaded? + libft.waitForRowsDisplayed("scan", rows_total, () => { + $("#cleanScanHistory").removeAttr("disabled", true); + $("html, body").animate({ + scrollTop: $("#scanResult").offset().top + }, 1000); + }); + } + + const json = neighbours_status[0].data; + if (json.action) { + common.alertMessage("alert-success", "Data successfully scanned"); + + const rows_total = $("#historyTable_scan > tbody > tr:not(.footable-detail-row)").length + 1; + const o = libft.process_history_v2({rows: [json]}, "scan"); + const {items} = o; + common.symbols.scan.push(o.symbols[0]); + + if (Object.prototype.hasOwnProperty.call(common.tables, "scan")) { + common.tables.scan.rows.load(items, true); + scrollTop(rows_total); + } else { + libft.destroyTable("scan"); + require(["footable"], () => { + // Is there a way to get an event when the table is destroyed? + setTimeout(() => { + libft.initHistoryTable(data, items, "scan", libft.columns_v2("scan"), true); + scrollTop(rows_total); + }, 200); + }); + } + } else { + common.alertMessage("alert-error", "Cannot scan data"); + } + }, + errorMessage: "Cannot upload data", + statusCode: { + 404: function () { + common.alertMessage("alert-error", "Cannot upload data, no server found"); + }, + 500: function () { + common.alertMessage("alert-error", "Cannot tokenize message: no text data"); + }, + 503: function () { + common.alertMessage("alert-error", "Cannot tokenize message: no text data"); + } + }, + server: get_server() + }); + } + + function getFuzzyHashes(data) { + function fillHashTable(rules) { + $("#hashTable tbody").empty(); + for (const [rule, hashes] of Object.entries(rules)) { + hashes.forEach((hash, i) => { + $("#hashTable tbody").append("<tr>" + + (i === 0 ? '<td rowspan="' + Object.keys(hashes).length + '">' + rule + "</td>" : "") + + "<td>" + hash + "</td></tr>"); + }); + } + $("#hash-card").slideDown(); + } + + common.query("plugins/fuzzy/hashes?flag=" + $("#fuzzy-flag").val(), { + data: data, + params: { + processData: false, + }, + method: "POST", + success: function (neighbours_status) { + const json = neighbours_status[0].data; + if (json.success) { + common.alertMessage("alert-success", "Message successfully processed"); + fillHashTable(json.hashes); + } else { + common.alertMessage("alert-error", "Unexpected error processing message"); + } + }, + server: get_server() + }); + } + + + libft.set_page_size("scan", $("#scan_page_size").val()); + libft.bindHistoryTableEventHandlers("scan", 3); + + $("#cleanScanHistory").off("click"); + $("#cleanScanHistory").on("click", (e) => { + e.preventDefault(); + if (!confirm("Are you sure you want to clean scan history?")) { // eslint-disable-line no-alert + return; + } + libft.destroyTable("scan"); + common.symbols.scan.length = 0; + $("#cleanScanHistory").attr("disabled", true); + }); + + function enable_disable_scan_btn() { + $("#scan button:not(#cleanScanHistory, #scanOptionsToggle)") + .prop("disabled", ($.trim($("textarea").val()).length === 0)); + } + enable_disable_scan_btn(); + $("textarea").on("input", () => { + enable_disable_scan_btn(); + }); + + $("#scanClean").on("click", () => { + $("#scan button:not(#cleanScanHistory, #scanOptionsToggle)").attr("disabled", true); + $("#scanForm")[0].reset(); + $("#scanResult").hide(); + $("#scanOutput tbody").remove(); + $("html, body").animate({scrollTop: 0}, 1000); + return false; + }); + + $(".card-close-btn").on("click", function () { + $(this).closest(".card").slideUp(); + }); + + $("[data-upload]").on("click", function () { + const source = $(this).data("upload"); + const data = $("#scanMsgSource").val(); + let headers = {}; + if ($.trim(data).length > 0) { + if (source === "scan") { + headers = ["IP", "User", "From", "Rcpt", "Helo", "Hostname"].reduce((o, header) => { + const value = $("#scan-opt-" + header.toLowerCase()).val(); + if (value !== "") o[header] = value; + return o; + }, {}); + if ($("#scan-opt-pass-all").prop("checked")) headers.Pass = "all"; + scanText(data, headers); + } else if (source === "compute-fuzzy") { + getFuzzyHashes(data); + } else { + if (source === "fuzzy") { + headers = { + flag: $("#fuzzyFlagText").val(), + weight: $("#fuzzyWeightText").val() + }; + } + uploadText(data, source, headers); + } + } else { + common.alertMessage("alert-error", "Message source field cannot be blank"); + } + return false; + }); + + return ui; + }); |