summaryrefslogtreecommitdiffstats
path: root/interface/js/app
diff options
context:
space:
mode:
Diffstat (limited to 'interface/js/app')
-rw-r--r--interface/js/app/common.js233
-rw-r--r--interface/js/app/config.js246
-rw-r--r--interface/js/app/graph.js252
-rw-r--r--interface/js/app/history.js306
-rw-r--r--interface/js/app/libft.js524
-rw-r--r--interface/js/app/rspamd.js486
-rw-r--r--interface/js/app/selectors.js145
-rw-r--r--interface/js/app/stats.js372
-rw-r--r--interface/js/app/symbols.js260
-rw-r--r--interface/js/app/upload.js243
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 = {
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ "\"": "&quot;",
+ "'": "&#39;",
+ "/": "&#x2F;",
+ "`": "&#x60;",
+ "=": "&#x3D;"
+ };
+ 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('&nbsp;<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(",&#8203;") + more("rcpt_smtp") + "]";
+ if (mime) {
+ full += " ";
+ shrt += " ";
+ }
+ }
+ if (mime) {
+ full += item.rcpt_mime.join(", ");
+ shrt += item.rcpt_mime.slice(0, rcpt_lim).join(",&#8203;") + 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;
+ });