You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

libft.js 27KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. /* global FooTable */
  2. define(["jquery", "app/common", "footable"],
  3. ($, common) => {
  4. "use strict";
  5. const ui = {};
  6. const columnsCustom = JSON.parse(localStorage.getItem("columns")) || {};
  7. let pageSizeTimerId = null;
  8. let pageSizeInvocationCounter = 0;
  9. function get_compare_function(table) {
  10. const compare_functions = {
  11. magnitude: function (e1, e2) {
  12. return Math.abs(e2.score) - Math.abs(e1.score);
  13. },
  14. name: function (e1, e2) {
  15. return e1.name.localeCompare(e2.name);
  16. },
  17. score: function (e1, e2) {
  18. return e2.score - e1.score;
  19. }
  20. };
  21. return compare_functions[common.getSelector("selSymOrder_" + table)];
  22. }
  23. function sort_symbols(o, compare_function) {
  24. return Object.keys(o)
  25. .map((key) => o[key])
  26. .sort(compare_function)
  27. .map((e) => e.str)
  28. .join("<br>\n");
  29. }
  30. // Public functions
  31. ui.formatBytesIEC = function (bytes) {
  32. // FooTable represents data as text even column type is "number".
  33. if (!Number.isInteger(Number(bytes)) || bytes < 0) return "NaN";
  34. const base = 1024;
  35. const exponent = Math.floor(Math.log(bytes) / Math.log(base));
  36. if (exponent > 8) return "∞";
  37. const value = parseFloat((bytes / (base ** exponent)).toPrecision(3));
  38. let unit = "BKMGTPEZY"[exponent];
  39. if (exponent) unit += "iB";
  40. return value + " " + unit;
  41. };
  42. ui.columns_v2 = function (table) {
  43. return [{
  44. name: "id",
  45. title: "ID",
  46. style: {
  47. minWidth: 130,
  48. overflow: "hidden",
  49. textOverflow: "ellipsis",
  50. wordBreak: "break-all",
  51. whiteSpace: "normal"
  52. }
  53. }, {
  54. name: "file",
  55. title: "File name",
  56. breakpoints: "sm",
  57. sortValue: (val) => ((typeof val === "undefined") ? "" : val)
  58. }, {
  59. name: "ip",
  60. title: "IP address",
  61. breakpoints: "lg",
  62. style: {
  63. "minWidth": "calc(14ch + 8px)",
  64. "word-break": "break-all"
  65. },
  66. // Normalize IPv4
  67. sortValue: (ip) => ((typeof ip === "string") ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0")
  68. }, {
  69. name: "sender_mime",
  70. title: "[Envelope From] From",
  71. breakpoints: "lg",
  72. style: {
  73. "minWidth": 100,
  74. "maxWidth": 200,
  75. "word-wrap": "break-word"
  76. }
  77. }, {
  78. name: "rcpt_mime_short",
  79. title: "[Envelope To] To/Cc/Bcc",
  80. breakpoints: "lg",
  81. filterable: false,
  82. classes: "d-none d-xl-table-cell",
  83. style: {
  84. "minWidth": 100,
  85. "maxWidth": 200,
  86. "word-wrap": "break-word"
  87. }
  88. }, {
  89. name: "rcpt_mime",
  90. title: "[Envelope To] To/Cc/Bcc",
  91. breakpoints: "all",
  92. style: {"word-wrap": "break-word"}
  93. }, {
  94. name: "subject",
  95. title: "Subject",
  96. breakpoints: "lg",
  97. style: {
  98. "word-break": "break-all",
  99. "minWidth": 150
  100. }
  101. }, {
  102. name: "action",
  103. title: "Action",
  104. style: {minwidth: 82}
  105. }, {
  106. name: "passthrough_module",
  107. title: '<div title="The module that has set the pre-result"><nobr>Pass-through</nobr> module</div>',
  108. breakpoints: "sm",
  109. style: {minWidth: 98, maxWidth: 98},
  110. sortValue: (val) => ((typeof val === "undefined") ? "" : val)
  111. }, {
  112. name: "score",
  113. title: "Score",
  114. style: {
  115. "maxWidth": 110,
  116. "text-align": "right",
  117. "white-space": "nowrap"
  118. },
  119. sortValue: function (val) { return Number(val.options.sortValue); }
  120. }, {
  121. name: "symbols",
  122. title: "Symbols" +
  123. '<div class="sym-order-toggle">' +
  124. '<br><span style="font-weight:normal;">Sort by:</span><br>' +
  125. '<div class="btn-group btn-group-xs btn-sym-order-' + table + '">' +
  126. '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-magnitude">' +
  127. '<input type="radio" class="btn-check" value="magnitude">Magnitude</label>' +
  128. '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-score">' +
  129. '<input type="radio" class="btn-check" value="score">Value</label>' +
  130. '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-name">' +
  131. '<input type="radio" class="btn-check" value="name">Name</label>' +
  132. "</div>" +
  133. "</div>",
  134. breakpoints: "all",
  135. style: {width: 550, maxWidth: 550}
  136. }, {
  137. name: "size",
  138. title: "Msg size",
  139. breakpoints: "lg",
  140. style: {minwidth: 50},
  141. formatter: ui.formatBytesIEC
  142. }, {
  143. name: "time_real",
  144. title: "Scan time",
  145. breakpoints: "lg",
  146. style: {maxWidth: 72},
  147. sortValue: function (val) { return Number(val); }
  148. }, {
  149. classes: "history-col-time",
  150. sorted: true,
  151. direction: "DESC",
  152. name: "time",
  153. title: "Time",
  154. sortValue: function (val) { return Number(val.options.sortValue); }
  155. }, {
  156. name: "user",
  157. title: "Authenticated user",
  158. breakpoints: "lg",
  159. style: {
  160. "minWidth": 100,
  161. "maxWidth": 130,
  162. "word-wrap": "break-word"
  163. }
  164. }].filter((col) => {
  165. switch (table) {
  166. case "history":
  167. return (col.name !== "file");
  168. case "scan":
  169. return ["ip", "sender_mime", "rcpt_mime_short", "rcpt_mime", "subject", "size", "user"]
  170. .every((name) => col.name !== name);
  171. default:
  172. return null;
  173. }
  174. });
  175. };
  176. ui.set_page_size = function (table, page_size, changeTablePageSize) {
  177. const n = parseInt(page_size, 10); // HTML Input elements return string representing a number
  178. if (n > 0) {
  179. common.page_size[table] = n;
  180. if (changeTablePageSize &&
  181. $("#historyTable_" + table + " tbody").is(":parent")) { // Table is not empty
  182. clearTimeout(pageSizeTimerId);
  183. const t = FooTable.get("#historyTable_" + table);
  184. if (t) {
  185. pageSizeInvocationCounter = 0;
  186. // Wait for input finish
  187. pageSizeTimerId = setTimeout(() => t.pageSize(n), 1000);
  188. } else if (++pageSizeInvocationCounter < 10) {
  189. // Wait for FooTable instance ready
  190. pageSizeTimerId = setTimeout(() => ui.set_page_size(table, n, true), 1000);
  191. }
  192. }
  193. }
  194. };
  195. ui.bindHistoryTableEventHandlers = function (table, symbolsCol) {
  196. function change_symbols_order(order) {
  197. $(".btn-sym-" + table + "-" + order).addClass("active").siblings().removeClass("active");
  198. const compare_function = get_compare_function(table);
  199. $.each(common.tables[table].rows.all, (i, row) => {
  200. const cell_val = sort_symbols(common.symbols[table][i], compare_function);
  201. row.cells[symbolsCol].val(cell_val, false, true);
  202. });
  203. }
  204. $("#selSymOrder_" + table).unbind().change(function () {
  205. const order = this.value;
  206. change_symbols_order(order);
  207. });
  208. $("#" + table + "_page_size").change((e) => ui.set_page_size(table, e.target.value, true));
  209. $(document).on("click", ".btn-sym-order-" + table + " input", function () {
  210. const order = this.value;
  211. $("#selSymOrder_" + table).val(order);
  212. change_symbols_order(order);
  213. });
  214. };
  215. ui.destroyTable = function (table) {
  216. $("#" + table + " .ft-columns-btn.show").trigger("click.bs.dropdown"); // Hide dropdown
  217. $("#" + table + " .ft-columns-btn").attr("disabled", true);
  218. if (common.tables[table]) {
  219. common.tables[table].destroy();
  220. delete common.tables[table];
  221. }
  222. };
  223. ui.initHistoryTable = function (data, items, table, columnsDefault, expandFirst, postdrawCallback) {
  224. /* eslint-disable no-underscore-dangle */
  225. FooTable.Cell.extend("collapse", function () {
  226. // call the original method
  227. this._super();
  228. // Copy cell classes to detail row tr element
  229. this._setClasses(this.$detail);
  230. });
  231. /* eslint-enable no-underscore-dangle */
  232. /* eslint-disable consistent-this, no-underscore-dangle, one-var-declaration-per-line */
  233. FooTable.actionFilter = FooTable.Filtering.extend({
  234. construct: function (instance) {
  235. this._super(instance);
  236. this.actions = ["reject", "add header", "greylist",
  237. "no action", "soft reject", "rewrite subject"];
  238. this.def = "Any action";
  239. this.$action = null;
  240. },
  241. $create: function () {
  242. this._super();
  243. const self = this;
  244. const $form_grp = $("<div/>", {
  245. class: "form-group d-inline-flex align-items-center"
  246. }).append($("<label/>", {
  247. class: "sr-only",
  248. text: "Action"
  249. })).prependTo(self.$form);
  250. $("<div/>", {
  251. class: "form-check form-check-inline",
  252. title: "Invert action match."
  253. }).append(
  254. self.$not = $("<input/>", {
  255. type: "checkbox",
  256. class: "form-check-input",
  257. id: "not_" + table
  258. }).on("change", {self: self}, self._onStatusDropdownChanged),
  259. $("<label/>", {
  260. class: "form-check-label",
  261. for: "not_" + table,
  262. text: "not"
  263. })
  264. ).appendTo($form_grp);
  265. self.$action = $("<select/>", {
  266. class: "form-select"
  267. }).on("change", {
  268. self: self
  269. }, self._onStatusDropdownChanged).append(
  270. $("<option/>", {
  271. text: self.def
  272. })).appendTo($form_grp);
  273. $.each(self.actions, (i, action) => {
  274. self.$action.append($("<option/>").text(action));
  275. });
  276. common.appendButtonsToFtFilterDropdown(self);
  277. },
  278. _onStatusDropdownChanged: function (e) {
  279. const {self} = e.data;
  280. const selected = self.$action.val();
  281. if (selected !== self.def) {
  282. const not = self.$not.is(":checked");
  283. let query = null;
  284. if (selected === "reject") {
  285. query = not ? "-reject OR soft" : "reject -soft";
  286. } else {
  287. query = not ? selected.replace(/(\b\w+\b)/g, "-$1") : selected;
  288. }
  289. self.addFilter("action", query, ["action"]);
  290. } else {
  291. self.removeFilter("action");
  292. }
  293. self.filter();
  294. }
  295. });
  296. /* eslint-enable consistent-this, no-underscore-dangle, one-var-declaration-per-line */
  297. const columns = (table in columnsCustom)
  298. ? columnsDefault.map((column) => $.extend({}, column, columnsCustom[table][column.name]))
  299. : columnsDefault.map((column) => column);
  300. common.tables[table] = FooTable.init("#historyTable_" + table, {
  301. breakpoints: common.breakpoints,
  302. cascade: true,
  303. columns: columns,
  304. rows: items,
  305. expandFirst: expandFirst,
  306. paging: {
  307. enabled: true,
  308. limit: 5,
  309. size: common.page_size[table]
  310. },
  311. filtering: {
  312. enabled: true,
  313. position: "left",
  314. connectors: false
  315. },
  316. sorting: {
  317. enabled: true
  318. },
  319. components: {
  320. filtering: FooTable.actionFilter
  321. },
  322. on: {
  323. "expand.ft.row": function (e, ft, row) {
  324. setTimeout(() => {
  325. const detail_row = row.$el.next();
  326. const order = common.getSelector("selSymOrder_" + table);
  327. detail_row.find(".btn-sym-" + table + "-" + order)
  328. .addClass("active").siblings().removeClass("active");
  329. }, 5);
  330. },
  331. "postdraw.ft.table": postdrawCallback
  332. }
  333. });
  334. // Column options dropdown
  335. (() => {
  336. function updateValue(checked, column, cellIdx) {
  337. const option = ["breakpoints", "visible"][cellIdx];
  338. const value = [(checked) ? "all" : column.breakpoints, !checked][cellIdx];
  339. FooTable.get("#historyTable_" + table).columns.get(column.name)[option] = value;
  340. return value;
  341. }
  342. const tbody = $("<tbody/>", {class: "table-group-divider"});
  343. $("#" + table + " .ft-columns-dropdown").empty().append(
  344. $("<table/>", {class: "table table-sm table-striped text-center"}).append(
  345. $("<thead/>").append(
  346. $("<tr/>").append(
  347. $("<th/>", {text: "Row", title: "Display column cells in a detail row on all screen widths"}),
  348. $("<th/>", {text: "Hidden", title: "Hide column completely"}),
  349. $("<th/>", {text: "Column name", class: "text-start"})
  350. )
  351. ),
  352. tbody
  353. ),
  354. $("<button/>", {
  355. type: "button",
  356. class: "btn btn-xs btn-secondary float-start",
  357. text: "Reset to default",
  358. click: () => {
  359. columnsDefault.forEach((column, i) => {
  360. const row = tbody[0].rows[i];
  361. [(column.breakpoints === "all"), (column.visible === false)].forEach((checked, cellIdx) => {
  362. if (row.cells[cellIdx].getElementsByTagName("input")[0].checked !== checked) {
  363. row.cells[cellIdx].getElementsByTagName("input")[0].checked = checked;
  364. updateValue(checked, column, cellIdx);
  365. delete columnsCustom[table];
  366. }
  367. });
  368. });
  369. }
  370. }),
  371. $("<button/>", {
  372. type: "button",
  373. class: "btn btn-xs btn-primary float-end btn-dropdown-apply",
  374. text: "Apply",
  375. title: "Save settings and redraw the table",
  376. click: (e) => {
  377. $(e.target).attr("disabled", true);
  378. FooTable.get("#historyTable_" + table).draw();
  379. localStorage.setItem("columns", JSON.stringify(columnsCustom));
  380. }
  381. })
  382. );
  383. function checkbox(i, column, cellIdx) {
  384. const option = ["breakpoints", "visible"][cellIdx];
  385. return $("<td/>").append($("<input/>", {
  386. "type": "checkbox",
  387. "class": "form-check-input",
  388. "data-table": table,
  389. "data-name": column.name,
  390. "checked": (option === "breakpoints" && column.breakpoints === "all") ||
  391. (option === "visible" && column.visible === false),
  392. "disabled": (option === "breakpoints" && columnsDefault[i].breakpoints === "all")
  393. }).change((e) => {
  394. const value = updateValue(e.target.checked, columnsDefault[i], cellIdx);
  395. if (value == null) { // eslint-disable-line no-eq-null, eqeqeq
  396. delete columnsCustom[table][column.name][option];
  397. } else {
  398. $.extend(true, columnsCustom, {
  399. [table]: {
  400. [column.name]: {
  401. [option]: value
  402. }
  403. }
  404. });
  405. }
  406. }));
  407. }
  408. $.each(columns, (i, column) => {
  409. tbody.append(
  410. $("<tr/>").append(
  411. checkbox(i, column, 0),
  412. checkbox(i, column, 1),
  413. $("<td/>", {
  414. class: "text-start",
  415. text: () => {
  416. switch (column.name) {
  417. case "passthrough_module": return "Pass-through module";
  418. case "symbols": return "Symbols";
  419. default: return column.title;
  420. }
  421. }
  422. })
  423. )
  424. );
  425. });
  426. $("#" + table + " .ft-columns-btn").removeAttr("disabled");
  427. })();
  428. };
  429. ui.preprocess_item = function (item) {
  430. function escape_HTML_array(arr) {
  431. arr.forEach((d, i) => { arr[i] = common.escapeHTML(d); });
  432. }
  433. for (const prop in item) {
  434. if (!{}.hasOwnProperty.call(item, prop)) continue;
  435. switch (prop) {
  436. case "rcpt_mime":
  437. case "rcpt_smtp":
  438. escape_HTML_array(item[prop]);
  439. break;
  440. case "symbols":
  441. Object.keys(item.symbols).forEach((key) => {
  442. const sym = item.symbols[key];
  443. if (!sym.name) {
  444. sym.name = key;
  445. }
  446. sym.name = common.escapeHTML(sym.name);
  447. if (sym.description) {
  448. sym.description = common.escapeHTML(sym.description);
  449. }
  450. if (sym.options) {
  451. escape_HTML_array(sym.options);
  452. }
  453. });
  454. break;
  455. default:
  456. if (typeof item[prop] === "string") {
  457. item[prop] = common.escapeHTML(item[prop]);
  458. }
  459. }
  460. }
  461. if (item.action === "clean" || item.action === "no action") {
  462. item.action = "<div style='font-size:11px' class='badge text-bg-success'>" + item.action + "</div>";
  463. } else if (item.action === "rewrite subject" || item.action === "add header" || item.action === "probable spam") {
  464. item.action = "<div style='font-size:11px' class='badge text-bg-warning'>" + item.action + "</div>";
  465. } else if (item.action === "spam" || item.action === "reject") {
  466. item.action = "<div style='font-size:11px' class='badge text-bg-danger'>" + item.action + "</div>";
  467. } else {
  468. item.action = "<div style='font-size:11px' class='badge text-bg-info'>" + item.action + "</div>";
  469. }
  470. const score_content = (item.score < item.required_score)
  471. ? "<span class='text-success'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>"
  472. : "<span class='text-danger'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>";
  473. item.score = {
  474. options: {
  475. sortValue: item.score
  476. },
  477. value: score_content
  478. };
  479. };
  480. ui.unix_time_format = function (tm) {
  481. const date = new Date(tm ? tm * 1000 : 0);
  482. return (common.locale)
  483. ? date.toLocaleString(common.locale)
  484. : date.toLocaleString();
  485. };
  486. ui.process_history_v2 = function (data, table) {
  487. // Display no more than rcpt_lim recipients
  488. const rcpt_lim = 3;
  489. const items = [];
  490. const unsorted_symbols = [];
  491. const compare_function = get_compare_function(table);
  492. $("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "']").show();
  493. $.each(data.rows,
  494. (i, item) => {
  495. function more(p) {
  496. const l = item[p].length;
  497. return (l > rcpt_lim) ? " … (" + l + ")" : "";
  498. }
  499. function format_rcpt(smtp, mime) {
  500. let full = "";
  501. let shrt = "";
  502. if (smtp) {
  503. full = "[" + item.rcpt_smtp.join(", ") + "] ";
  504. shrt = "[" + item.rcpt_smtp.slice(0, rcpt_lim).join(",&#8203;") + more("rcpt_smtp") + "]";
  505. if (mime) {
  506. full += " ";
  507. shrt += " ";
  508. }
  509. }
  510. if (mime) {
  511. full += item.rcpt_mime.join(", ");
  512. shrt += item.rcpt_mime.slice(0, rcpt_lim).join(",&#8203;") + more("rcpt_mime");
  513. }
  514. return {full: full, shrt: shrt};
  515. }
  516. function get_symbol_class(name, score) {
  517. if (name.match(/^GREYLIST$/)) {
  518. return "symbol-special";
  519. }
  520. if (score < 0) {
  521. return "symbol-negative";
  522. } else if (score > 0) {
  523. return "symbol-positive";
  524. }
  525. return null;
  526. }
  527. ui.preprocess_item(item);
  528. Object.values(item.symbols).forEach((sym) => {
  529. sym.str = '<span class="symbol-default ' + get_symbol_class(sym.name, sym.score) + '"><strong>';
  530. if (sym.description) {
  531. sym.str += '<abbr title="' + sym.description + '">' + sym.name + "</abbr>";
  532. } else {
  533. sym.str += sym.name;
  534. }
  535. sym.str += "</strong> (" + sym.score + ")</span>";
  536. if (sym.options) {
  537. sym.str += " [" + sym.options.join(",") + "]";
  538. }
  539. });
  540. unsorted_symbols.push(item.symbols);
  541. item.symbols = sort_symbols(item.symbols, compare_function);
  542. if (table === "scan") {
  543. item.unix_time = (new Date()).getTime() / 1000;
  544. }
  545. item.time = {
  546. value: ui.unix_time_format(item.unix_time),
  547. options: {
  548. sortValue: item.unix_time
  549. }
  550. };
  551. item.time_real = item.time_real.toFixed(3);
  552. item.id = item["message-id"];
  553. if (table === "history") {
  554. let rcpt = {};
  555. if (!item.rcpt_mime.length) {
  556. rcpt = format_rcpt(true, false);
  557. } else if (
  558. $(item.rcpt_mime).not(item.rcpt_smtp).length !== 0 ||
  559. $(item.rcpt_smtp).not(item.rcpt_mime).length !== 0
  560. ) {
  561. rcpt = format_rcpt(true, true);
  562. } else {
  563. rcpt = format_rcpt(false, true);
  564. }
  565. item.rcpt_mime_short = rcpt.shrt;
  566. item.rcpt_mime = rcpt.full;
  567. if (item.sender_mime !== item.sender_smtp) {
  568. item.sender_mime = "[" + item.sender_smtp + "] " + item.sender_mime;
  569. }
  570. }
  571. items.push(item);
  572. });
  573. return {items: items, symbols: unsorted_symbols};
  574. };
  575. return ui;
  576. });