File: //proc/1/cwd/usr/share/rspamd/www/js/app/upload.js
/*
* Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
*/
define(["app/common", "app/libft"],
(common, libft) => {
"use strict";
const ui = {};
const fileSet = {files: null, index: null};
const lastReqContext = {
classifiers: {config_id: null, server: null},
storages: {config_id: null, server: null}
};
let scanTextHeaders = {};
// Smooth-scroll the window to `top` via a requestAnimationFrame tween,
// as the former jQuery .animate({scrollTop}) did. Unlike the native
// scrollTo({behavior:"smooth"}), this is not interrupted by the reflows
// Tabulator triggers while rendering, and — like the rest of the UI's
// animations (the show/hide slides, the refresh spinner) — does not
// honour prefers-reduced-motion, preserving the always-smooth scroll.
function smoothScrollTo(top) {
const start = window.scrollY;
const distance = top - start;
if (!distance) return;
const duration = 400; // ms; matches the former jQuery default
let startTime = null;
function step(now) {
if (startTime === null) startTime = now;
const progress = Math.min((now - startTime) / duration, 1);
// easeInOutQuad, approximating jQuery's default "swing" easing
const eased = progress < 0.5
? 2 * progress * progress
: 1 - ((-2 * progress + 2) ** 2) / 2;
window.scrollTo(0, start + distance * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function uploadText(data, url, headers, method = "POST") {
return new Promise((resolve) => {
function server() {
if (common.getSelector("selSrv") === "All SERVERS" &&
common.getSelector("selLearnServers") === "random") {
const servers = Array.from(document.getElementById("selSrv").options)
.slice(1)
.map((o) => o.value);
return servers[Math.floor(Math.random() * servers.length)];
}
return null;
}
common.query(url, {
data: data,
method: method,
headers: headers,
success: function (json, jqXHR) {
common.alertMessage("alert-success", "Data successfully uploaded");
if (jqXHR.status !== 200) {
common.alertMessage("alert-info", jqXHR.statusText);
}
},
complete: () => resolve(),
server: server()
});
});
}
function enable_disable_scan_btn(disable) {
const scanBtns = "#scan button:not(#cleanScanHistory, #deleteHashesBtn, #scanOptionsToggle, .tab-columns-btn)";
const empty = document.getElementById("scanMsgSource").value.trim().length === 0;
const isDisabled = disable || empty;
document.querySelectorAll(scanBtns).forEach((btn) => { btn.disabled = isDisabled; });
}
function scanText(data) {
enable_disable_scan_btn(true);
common.query("checkv2", {
data: data,
method: "POST",
headers: scanTextHeaders,
success: function (neighbours_status) {
const json = neighbours_status[0].data;
// Extract fuzzy_hashes from milter headers if available
const fuzzyHeader = json.milter?.add_headers?.["X-Rspamd-Fuzzy"];
if (fuzzyHeader?.value) {
json.fuzzy_hashes = fuzzyHeader.value
.split(",")
.map((h) => h.trim())
.filter((h) => h.length > 0);
}
if (json.action) {
common.alertMessage("alert-success", "Data successfully scanned");
const o = libft.process_history_v2({rows: [json]}, "scan");
const {items} = o;
common.symbols.scan.push(o.symbols[0]);
if (fileSet.files) items[0].file = fileSet.files[fileSet.index].name;
if (Object.prototype.hasOwnProperty.call(common.tables, "scan")) {
common.tables.scan.addData(items);
} else {
libft.initHistoryTable(data, items, "scan", libft.columns_v2("scan"), true,
() => {
const {files} = fileSet;
if (files && fileSet.index < files.length - 1) {
common.fileUtils.readFile(files, (result) => {
const {index} = fileSet;
if (index === files.length - 1) {
document.getElementById("scanMsgSource").value = result;
common.fileUtils.setFileInputFiles("#formFile", files, index);
}
scanText(result);
}, ++fileSet.index);
} else {
enable_disable_scan_btn();
const applyBtns = "#cleanScanHistory, #scan .tab-columns-dropdown .btn-dropdown-apply";
document.querySelectorAll(applyBtns).forEach((el) => { el.disabled = false; });
libft.bindFuzzyHashButtons("scan");
const scanResult = document.getElementById("scanResult");
const top = scanResult.getBoundingClientRect().top + window.scrollY;
smoothScrollTo(top);
}
});
}
} else {
common.alertMessage("alert-danger", "Cannot scan data");
}
},
error: enable_disable_scan_btn,
errorMessage: "Cannot upload data",
statusCode: {
404: function () {
common.logError({
server: common.getServer(),
endpoint: "checkv2",
message: "Cannot upload data, no server found",
httpStatus: 404,
errorType: "http_error"
});
},
500: function () {
common.alertMessage("alert-danger", "Cannot tokenize message: no text data");
},
503: function () {
common.alertMessage("alert-danger", "Cannot tokenize message: no text data");
}
},
server: common.getServer()
});
}
function getFuzzyHashes(data) {
function fillHashTable(rules) {
const tbody = document.querySelector("#hashTable tbody");
tbody.replaceChildren();
for (const [rule, hashes] of Object.entries(rules)) {
const rowspan = hashes.length;
hashes.forEach((hash, i) => {
const tr = common.el("tr", null);
if (i === 0) tr.append(common.el("td", {rowspan, text: rule}));
tr.append(common.el("td", {text: hash}));
tbody.append(tr);
});
}
common.show("#hash-card", true);
}
common.query("plugins/fuzzy/hashes?flag=" + document.getElementById("fuzzy-flag").value, {
data: data,
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-danger", "Unexpected error processing message");
}
},
server: common.getServer()
});
}
libft.set_page_size("scan", document.getElementById("scan_page_size").value);
libft.bindHistoryTableEventHandlers("scan", 5);
document.getElementById("cleanScanHistory").addEventListener("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;
document.getElementById("cleanScanHistory").disabled = true;
});
enable_disable_scan_btn();
document.getElementById("scanClean").addEventListener("click", (e) => {
e.preventDefault();
enable_disable_scan_btn(true);
document.getElementById("scanForm").reset();
smoothScrollTo(0);
});
document.querySelectorAll(".card-close-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
common.hide(e.currentTarget.closest(".card"), true);
});
});
function getScanTextHeaders() {
scanTextHeaders = ["IP", "User", "From", "Rcpt", "Helo", "Hostname"].reduce((o, header) => {
const {value} = document.getElementById("scan-opt-" + header.toLowerCase());
if (value !== "") o[header] = value;
return o;
}, {});
if (document.getElementById("scan-opt-pass-all").checked) scanTextHeaders.Pass = "all";
}
document.querySelectorAll("[data-upload]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const {upload: source} = e.currentTarget.dataset;
const data = document.getElementById("scanMsgSource").value;
if (data.trim().length > 0) {
if (source === "checkv2") {
getScanTextHeaders();
scanText(data);
} else if (source === "compute-fuzzy") {
getFuzzyHashes(data);
} else {
const headers = {};
const isBayesLearn = source === "learnham" || source === "learnspam" || source === "learnclass";
if (isBayesLearn) {
const classifier = document.getElementById("classifier").value;
if (classifier) headers.classifier = classifier;
}
if (source === "learnclass") {
const bayesClass = document.getElementById("bayes-class").value;
if (!bayesClass) {
common.alertMessage("alert-danger", "Classifier has no classes configured");
return;
}
headers.class = bayesClass;
}
if (source === "fuzzyadd") {
headers.flag = document.getElementById("fuzzyFlagText").value;
headers.weight = document.getElementById("fuzzyWeightText").value;
}
if (source === "fuzzydel") {
headers.flag = document.getElementById("fuzzyFlagText").value;
}
uploadText(data, source, headers);
}
} else {
common.alertMessage("alert-danger", "Message source field cannot be blank");
}
});
});
function setDelhashButtonsDisabled(disabled = true) {
["#deleteHashesBtn", "#clearHashesBtn"].forEach((s) => {
document.querySelector(s).disabled = disabled;
});
}
/**
* Parse a textarea (or any input) value into an array of non-empty tokens.
* Splits on commas, semicolons or any whitespace (space, tab, newline).
*
* @param {string} selector - CSS selector for the input element.
* @returns {string[]} - Trimmed, non-empty tokens.
*/
function parseHashes(selector) {
return document.querySelector(selector).value
.split(/[,\s;]+/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
document.getElementById("fuzzyDelList").addEventListener("input", () => {
const hasTokens = parseHashes("#fuzzyDelList").length > 0;
setDelhashButtonsDisabled(!hasTokens);
});
document.getElementById("deleteHashesBtn").addEventListener("click", () => {
document.getElementById("fuzzyDelList").disabled = true;
setDelhashButtonsDisabled();
document.querySelector("#deleteHashesBtn .btn-label").textContent = "Deleting…";
const hashes = parseHashes("#fuzzyDelList");
const promises = hashes.map((h) => {
const headers = {
flag: document.getElementById("fuzzyFlagText").value,
Hash: h
};
return uploadText(null, "fuzzydelhash", headers, "GET");
});
Promise.all(promises).finally(() => {
document.getElementById("fuzzyDelList").disabled = false;
setDelhashButtonsDisabled(false);
document.querySelector("#deleteHashesBtn .btn-label").textContent = "Delete hashes";
});
});
document.getElementById("clearHashesBtn").addEventListener("click", () => {
const delList = document.getElementById("fuzzyDelList");
delList.value = "";
delList.focus();
setDelhashButtonsDisabled();
});
function multiple_files_cb(files) {
// eslint-disable-next-line no-alert
if (files.length < 10 || confirm("Are you sure you want to scan " + files.length + " files?")) {
getScanTextHeaders();
common.fileUtils.readFile(files, (result) => scanText(result));
}
}
common.fileUtils.setupFileHandling("#scanMsgSource", "#formFile", fileSet, enable_disable_scan_btn, multiple_files_cb);
/**
* Returns `true` if we should skip the request as configuration is not changed,
* otherwise bumps the request context cache and returns `false`.
*
* @param {string} server
* Name of the currently selected Rspamd neighbour.
* @param {"classifiers"|"storages"} key
* Which endpoint’s cache to check.
* @returns {boolean}
*/
function shouldSkipRequest(server, key) {
const servers = JSON.parse(sessionStorage.getItem("Credentials") || "{}");
const config_id = servers[server]?.data?.config_id;
const last = lastReqContext[key];
if ((config_id && config_id === last.config_id) ||
(!config_id && server === last.server)) {
return true;
}
lastReqContext[key] = {config_id, server};
return false;
}
// Switch UI mode based on selected classifier type
function updateBayesUI() {
const classifierSel = document.getElementById("classifier");
const classSel = document.getElementById("bayes-class");
const binaryButtons = document.getElementById("binary-learn-buttons");
const learnClassBtn = document.getElementById("learn-class-btn");
const selectedOption = classifierSel.options[classifierSel.selectedIndex];
const classifierType = selectedOption ? common.data(selectedOption, "type") : null;
const classes = selectedOption ? common.data(selectedOption, "classes") ?? [] : [];
if (classifierType === "multi-class") {
// Multi-class mode: show class dropdown and Learn button
classSel.replaceChildren();
if (Array.isArray(classes) && classes.length > 0) {
classes.forEach((cls) => {
classSel.append(common.el("option", {value: cls, text: cls}));
});
} else {
// No classes available - this shouldn't happen with valid config
classSel.append(common.el("option", {value: "", text: "No classes available"}));
}
classSel.classList.remove("d-none");
binaryButtons.classList.add("d-none");
learnClassBtn.classList.remove("d-none");
} else {
// Binary mode: show HAM/SPAM buttons
classSel.classList.add("d-none");
binaryButtons.classList.remove("d-none");
learnClassBtn.classList.add("d-none");
}
}
ui.getClassifiers = function () {
if (!common.read_only) {
const server = common.getServer();
const sel = document.getElementById("classifier");
const hadOptions = sel.children.length > 0; // remember pre-state
// Skip request only if we already had options populated for this config/server
if (shouldSkipRequest(server, "classifiers") && hadOptions) return;
sel.replaceChildren();
common.query("bayes/classifiers", {
success: function (data) {
const response = data[0].data;
// eslint-disable-next-line no-useless-assignment
let classifiers = [];
// Handle both old and new response formats
if (Array.isArray(response)) {
// Old format: ["classifier1", "classifier2"] - all binary
classifiers = response.map((name) => ({
name: name,
type: "binary",
per_user: false,
classes: ["spam", "ham"]
}));
} else if (response?.classifiers) {
// New format: {classifiers: [{name, type, per_user, classes}]}
({classifiers} = response);
} else {
// Unexpected response format
common.alertMessage("alert-warning", "Unable to load classifiers list");
return;
}
// Add "All classifiers" only if no multi-class classifiers present
const hasMultiClass = classifiers.some((cl) => cl.type === "multi-class");
if (!hasMultiClass) sel.append(common.el("option", {value: "", text: "All classifiers"}));
classifiers.forEach((cl) => {
const badges = [];
if (cl.type === "multi-class") badges.push("[multi-class]");
if (cl.per_user) badges.push("[per-user]");
const label = cl.name + (badges.length ? " " + badges.join(" ") : "");
const option = common.el("option", {value: cl.name, text: label});
// Store metadata in a WeakMap (not as DOM attributes)
common.data(option, "type", cl.type);
common.data(option, "per-user", cl.per_user);
common.data(option, "classes", cl.classes || []);
sel.append(option);
});
// Initialize UI state for the first classifier
updateBayesUI();
},
server: server
});
}
};
const fuzzyWidgets = [
{
picker: "#fuzzy-flag-picker",
input: "#fuzzy-flag",
container: (picker) => picker.parentElement,
includeReadOnly: true,
requiresWritable: false,
emptyText: "No fuzzy storages"
},
{
picker: "#fuzzyFlagText-picker",
input: "#fuzzyFlagText",
container: (picker) => picker.closest("div.card"),
includeReadOnly: false,
requiresWritable: true,
emptyText: "No writable storages"
}
];
let fuzzyChangeBound = false;
function toggleWidgets(showPicker, showInput) {
fuzzyWidgets.forEach(({picker, input}) => {
(showPicker ? common.show : common.hide)(picker);
(showInput ? common.show : common.hide)(input);
});
}
function setWidgetsDisabled(disable, predicate = () => true) {
fuzzyWidgets.forEach((widget) => {
if (!predicate(widget)) return;
const pickerEl = document.querySelector(widget.picker);
widget.container(pickerEl)?.classList.toggle("disabled", disable);
});
}
ui.getFuzzyStorages = function () {
const server = common.getServer();
if (shouldSkipRequest(server, "storages")) return;
fuzzyWidgets.forEach(({picker, container}) => {
container(document.querySelector(picker))?.removeAttribute("title");
});
common.query("plugins/fuzzy/storages", {
success: function (data) {
const storages = data[0].data.storages || {};
const hasWritableStorages = Object.keys(storages).some((name) => !storages[name].read_only);
toggleWidgets(true, false);
setWidgetsDisabled(!hasWritableStorages, (widget) => widget.requiresWritable);
// The change handler just mirrors the picker value into its
// paired input; the widgets are static, so bind it once.
if (!fuzzyChangeBound) {
fuzzyWidgets.forEach(({picker, input}) => {
const selEl = document.querySelector(picker);
const inputEl = document.querySelector(input);
selEl.addEventListener("change", () => { inputEl.value = selEl.value; });
});
fuzzyChangeBound = true;
}
fuzzyWidgets.forEach((widget) => {
const {picker, input, includeReadOnly, emptyText} = widget;
const selEl = document.querySelector(picker);
selEl.replaceChildren();
const applicableStorages = Object.entries(storages)
.filter(([, info]) => includeReadOnly || !info.read_only);
applicableStorages.forEach(([name, info]) => {
Object.entries(info.flags).forEach(([symbol, val]) => {
selEl.append(common.el("option", {value: val, text: `${name}:${symbol} (${val})`}));
});
});
const inputEl = document.querySelector(input);
if (selEl.children.length > 0) {
inputEl.value = selEl.value;
} else {
selEl.append(common.el("option", {value: "", text: emptyText}));
inputEl.value = "";
}
});
},
error: function (_result, _jqXHR, _textStatus, errorThrown) {
if (errorThrown === "fuzzy_check is not enabled") {
toggleWidgets(true, false);
setWidgetsDisabled(true);
fuzzyWidgets.forEach(({picker, container}) => {
const pickerEl = document.querySelector(picker);
pickerEl.replaceChildren();
pickerEl.append(common.el("option", {value: "", text: "fuzzy_check disabled"}));
common.show(pickerEl);
container(pickerEl)
?.setAttribute("title", "fuzzy_check module is not enabled in server configuration.");
});
} else {
toggleWidgets(false, true);
setWidgetsDisabled(false);
}
},
server: server
});
};
// Initialize classifier dropdown change handler
document.getElementById("classifier").addEventListener("change", updateBayesUI);
return ui;
});