File: //proc/1/task/1/root/usr/share/rspamd/www/js/app/libft.js
/* global FooTable */
define(["jquery", "app/common", "footable"],
($, common) => {
"use strict";
const ui = {};
const columnsCustom = JSON.parse(localStorage.getItem("columns")) || {};
let pageSizeTimerId = null;
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: "file",
title: "File name",
breakpoints: "sm",
sortValue: (val) => ((typeof val === "undefined") ? "" : val)
}, {
name: "ip",
title: "IP address",
breakpoints: "lg",
style: {
"minWidth": "calc(14ch + 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: "lg",
style: {
"minWidth": 100,
"maxWidth": 200,
"word-wrap": "break-word"
}
}, {
name: "rcpt_mime_short",
title: "[Envelope To] To/Cc/Bcc",
breakpoints: "lg",
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: "lg",
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"><nobr>Pass-through</nobr> module</div>',
breakpoints: "sm",
style: {minWidth: 98, maxWidth: 98},
sortValue: (val) => ((typeof val === "undefined") ? "" : val)
}, {
name: "score",
title: "Score",
style: {
"maxWidth": 110,
"text-align": "right",
"white-space": "nowrap"
},
sortValue: (val) => 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: "lg",
style: {minwidth: 50},
formatter: ui.formatBytesIEC
}, {
name: "time_real",
title: "Scan time",
breakpoints: "lg",
style: {maxWidth: 72},
sortValue: (val) => Number(val)
}, {
classes: "history-col-time",
sorted: true,
direction: "DESC",
name: "time",
title: "Time",
sortValue: (val) => Number(val.options.sortValue)
}, {
name: "user",
title: "Authenticated user",
breakpoints: "lg",
style: {
"minWidth": 100,
"maxWidth": 130,
"word-wrap": "break-word"
}
}].filter((col) => {
switch (table) {
case "history":
return (col.name !== "file");
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
if (common.tables[table]) {
// Table exists - debounce rapid changes (e.g., spin button clicks)
clearTimeout(pageSizeTimerId);
pageSizeTimerId = setTimeout(() => {
common.tables[table]?.pageSize(n);
}, 1000);
} else {
// Table doesn't exist - wait for initialization with event
$("#historyTable_" + table).one("postinit.ft.table", () => {
common.tables[table]?.pageSize(n);
});
}
}
}
};
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) {
$("#" + table + " .ft-columns-btn.show").trigger("click.bs.dropdown"); // Hide dropdown
$("#" + table + " .ft-columns-btn").attr("disabled", true);
if (common.tables[table]) {
const promise = common.tables[table].destroy();
delete common.tables[table];
return promise;
}
return new $.Deferred().resolve().promise();
};
ui.initHistoryTable = function (data, items, table, columnsDefault, expandFirst, postdrawCallback) {
/* 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 */
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;
if (self.$input && self.$input.length && !self.$input.parent().find(".search-syntax-icon").length) {
self.$input.parent().css("position", "relative");
const $icon = $("<i/>", {
class: "fas fa-circle-question search-syntax-icon text-muted",
title: "Search syntax: match all rows containing\n\n" +
"\"exact phrase\" — exact string (including spaces)\n" +
"term1 OR term2 — either term\n" +
"term1 AND term2 — both terms\n" +
"term1 term2 — both terms (same as AND)\n" +
"term1 -term2 — term1 but exclude rows with term2"
});
$icon.insertAfter(self.$input);
}
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));
});
common.appendButtonsToFtFilterDropdown(self);
},
_onStatusDropdownChanged: function (e) {
const {self} = e.data;
const selected = self.$action.val();
if (selected !== self.def) {
const not = self.$not.is(":checked");
// eslint-disable-next-line no-useless-assignment
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();
},
draw: function () {
// Ensure the dropdown reflects the default value when filters are cleared.
this._super();
const actionFilter = this.find("action");
const isActionFilterApplied = actionFilter instanceof FooTable.Filter;
if (this.$action && !isActionFilterApplied) this.$action.val(this.def);
}
});
/* eslint-enable consistent-this, no-underscore-dangle */
const columns = (table in columnsCustom)
? columnsDefault.map((column) => $.extend({}, column, columnsCustom[table][column.name]))
: columnsDefault.map((column) => column);
common.tables[table] = FooTable.init("#historyTable_" + table, {
breakpoints: common.breakpoints,
cascade: true,
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: {
"expanded.ft.row": function (e, ft, row) {
const detail_row = row.$el.next();
const order = common.getSelector("selSymOrder_" + table);
detail_row.find(".btn-sym-" + table + "-" + order)
.addClass("active").siblings().removeClass("active");
},
"postdraw.ft.table": postdrawCallback
}
});
// Column options dropdown
(() => {
function updateValue(checked, column, cellIdx) {
const option = ["breakpoints", "visible"][cellIdx];
const value = [(checked) ? "all" : column.breakpoints, !checked][cellIdx];
FooTable.get("#historyTable_" + table).columns.get(column.name)[option] = value;
return value;
}
const tbody = $("<tbody/>", {class: "table-group-divider"});
$("#" + table + " .ft-columns-dropdown").empty().append(
$("<table/>", {class: "table table-sm table-striped text-center"}).append(
$("<thead/>").append(
$("<tr/>").append(
$("<th/>", {text: "Row", title: "Display column cells in a detail row on all screen widths"}),
$("<th/>", {text: "Hidden", title: "Hide column completely"}),
$("<th/>", {text: "Column name", class: "text-start"})
)
),
tbody
),
$("<button/>", {
type: "button",
class: "btn btn-xs btn-secondary float-start",
text: "Reset to default",
click: () => {
columnsDefault.forEach((column, i) => {
const row = tbody[0].rows[i];
[(column.breakpoints === "all"), (column.visible === false)].forEach((checked, cellIdx) => {
if (row.cells[cellIdx].getElementsByTagName("input")[0].checked !== checked) {
row.cells[cellIdx].getElementsByTagName("input")[0].checked = checked;
updateValue(checked, column, cellIdx);
delete columnsCustom[table];
}
});
});
}
}),
$("<button/>", {
type: "button",
class: "btn btn-xs btn-primary float-end btn-dropdown-apply",
text: "Apply",
title: "Save settings and redraw the table",
click: (e) => {
$(e.target).attr("disabled", true);
FooTable.get("#historyTable_" + table).draw();
localStorage.setItem("columns", JSON.stringify(columnsCustom));
}
})
);
function checkbox(i, column, cellIdx) {
const option = ["breakpoints", "visible"][cellIdx];
return $("<td/>").append($("<input/>", {
"type": "checkbox",
"class": "form-check-input",
"data-table": table,
"data-name": column.name,
"checked": (option === "breakpoints" && column.breakpoints === "all") ||
(option === "visible" && column.visible === false),
"disabled": (option === "breakpoints" && columnsDefault[i].breakpoints === "all")
}).change((e) => {
const value = updateValue(e.target.checked, columnsDefault[i], cellIdx);
if (value == null) { // eslint-disable-line no-eq-null, eqeqeq
delete columnsCustom[table][column.name][option];
} else {
$.extend(true, columnsCustom, {
[table]: {
[column.name]: {
[option]: value
}
}
});
}
}));
}
$.each(columns, (i, column) => {
tbody.append(
$("<tr/>").append(
checkbox(i, column, 0),
checkbox(i, column, 1),
$("<td/>", {
class: "text-start",
text: () => {
switch (column.name) {
case "passthrough_module": return "Pass-through module";
case "symbols": return "Symbols";
default: return column.title;
}
}
})
)
);
});
$("#" + table + " .ft-columns-btn").removeAttr("disabled");
})();
};
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();
};
function isFuzzySymbol(sym) {
if (!sym.options) return false;
return sym.options.some((opt) => (/^\d+:[a-f0-9]+:[\d.]+:/).test(opt));
}
function attachFuzzyIndices(sym, fuzzyHashesArray, fuzzyHashIndex) {
sym.fuzzyHashIndices = [];
if (!fuzzyHashesArray || Object.keys(fuzzyHashIndex).length === 0) return;
const foundIndices = new Set();
sym.options.forEach((opt) => {
const match = opt.match(/^\d+:([a-f0-9]+):[\d.]+:/);
if (match) {
const [,shortHash] = match;
const indices = fuzzyHashIndex[shortHash];
if (Array.isArray(indices)) indices.forEach((i) => foundIndices.add(i));
}
});
sym.fuzzyHashIndices = Array.from(foundIndices).sort((a, b) => a - b);
}
function generateFuzzySearchData(sym, fuzzyHashesArray) {
if (!sym.fuzzyHashIndices?.length) return "";
const fullHashes = sym.fuzzyHashIndices
.filter((i) => i >= 0 && i < fuzzyHashesArray.length)
.map((i) => fuzzyHashesArray[i]);
return `<span class="visually-hidden">${common.escapeHTML(fullHashes.join(" "))}</span>`;
}
function generateFuzzyActions(sym, table, item) {
const hasHashes = sym.fuzzyHashIndices?.length > 0;
// eslint-disable-next-line init-declarations
let copyTitle, delistTitle;
if (hasHashes) {
copyTitle = "Copy full hashes to clipboard";
delistTitle = "Open bl.rspamd.com delisting page";
} else if (table === "history") {
copyTitle = "Full fuzzy hashes are not available for this message";
delistTitle = copyTitle;
} else {
copyTitle = "Full fuzzy hashes are not available. Enable milter_headers module with 'fuzzy-hashes' routine";
delistTitle = copyTitle;
}
function makeButton(cssClass, action, icon, label, title) {
const dataAttrs = hasHashes
? `data-indices='${common.escapeHTML(JSON.stringify(sym.fuzzyHashIndices))}' ` +
`data-hashes='${common.escapeHTML(JSON.stringify(item.fuzzy_hashes))}' data-table="${table}"`
: `data-table="${table}"`;
const disabled = hasHashes ? "" : " disabled";
const button = `<button class="btn btn-xs ${cssClass} ${action}${disabled}" ${dataAttrs}${disabled} ` +
`title="${title}"><i class="fas ${icon}"></i> ${label}</button>`;
return hasHashes ? button : `<span title="${title}">${button}</span>`;
}
const copyBtn = makeButton("btn-outline-secondary", "fuzzy-copy", "fa-copy", "Copy", copyTitle);
const delistBtn = makeButton("btn-outline-primary", "fuzzy-delist", "fa-external-link", "Delist", delistTitle);
return `<span class="fuzzy-hash-actions d-inline-flex gap-1 ms-1 align-baseline">${copyBtn}${delistBtn}</span>`;
}
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);
common.show("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "]");
$.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);
// Build fuzzy hash index for this item
const fuzzyHashIndex = {};
if (Array.isArray(item.fuzzy_hashes)) {
item.fuzzy_hashes.forEach((fullHash, idx) => {
const shortHash = fullHash.substring(0, 10);
if (!fuzzyHashIndex[shortHash]) fuzzyHashIndex[shortHash] = [];
fuzzyHashIndex[shortHash].push(idx);
});
}
Object.values(item.symbols).forEach((sym) => {
sym.str = `
<span class="symbol-default ${get_symbol_class(sym.name, sym.score)} ${sym.description ? "has-description" : ""}" tabindex="0">
<strong>${sym.name}</strong>
${sym.description ? `<span class="symbol-description"> • ${sym.description}</span>` : ""}
(${sym.score})
</span>`;
if (sym.options) {
sym.str += ` [${sym.options.join(",")}]`;
if (isFuzzySymbol(sym)) {
attachFuzzyIndices(sym, item.fuzzy_hashes, fuzzyHashIndex);
sym.str += generateFuzzySearchData(sym, item.fuzzy_hashes);
sym.str += generateFuzzyActions(sym, table, item);
}
}
});
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") {
// eslint-disable-next-line no-useless-assignment
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.bindFuzzyHashButtons = function (table) {
function bindAction(action, handler) {
const selector = `.fuzzy-${action}[data-table="${table}"]:not(:disabled)`;
$(document).off("click", selector);
$(document).on("click", selector, function (e) {
e.preventDefault();
e.stopPropagation();
// eslint-disable-next-line init-declarations
let hashes, indices;
try {
indices = JSON.parse($(this).attr("data-indices") || "[]");
hashes = JSON.parse($(this).attr("data-hashes") || "[]");
} catch (err) {
common.alertMessage("alert-danger", "Invalid hash data: " + err.message);
return;
}
if (indices.length === 0 || hashes.length === 0) {
common.alertMessage("alert-warning", "No full hashes available");
return;
}
const fullHashes = [...new Set(indices.map((i) => hashes[i]))];
handler.call(this, fullHashes);
});
}
bindAction("copy", function (fullHashes) {
const textToCopy = fullHashes.join("\n");
common.copyToClipboard(textToCopy)
.then(() => {
const btn = $(this);
const originalHtml = btn.html();
btn.html('<i class="fas fa-check"></i> Copied!');
setTimeout(() => btn.html(originalHtml), 2000);
})
.catch((err) => {
common.alertMessage("alert-danger", "Copy failed: " + err.message);
});
});
bindAction("delist", (fullHashes) => {
const url = "https://bl.rspamd.com/removal?type=fuzzy&hash=" + encodeURIComponent(fullHashes.join(","));
window.open(url, "_blank");
});
};
return ui;
});