]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] WebUI: Add fuzzy hash copy and delist buttons
authorAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 27 Jan 2026 10:16:16 +0000 (13:16 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 27 Jan 2026 14:37:11 +0000 (17:37 +0300)
Add UI controls for managing fuzzy hashes in History and Scan tables:
- Copy button to copy full hashes to clipboard (newline-separated)
- Delist button to open bl.rspamd.com removal page with hashes
- Buttons are disabled (with tooltips) when hashes are unavailable
- Hashes are searchable via filter input

interface/css/rspamd.css
interface/js/app/history.js
interface/js/app/libft.js
interface/js/app/upload.js

index d8b35ee65deb7ffb1842db0fd5b924e5d4049386..ed6e884a3d7ce1dac4d1e6194c6f4213c7953c55 100644 (file)
@@ -507,6 +507,15 @@ table#symbolsTable input[type="number"] {
 .footable-filtering-search .dropdown-menu .sym-order-toggle {
     display: none;
 }
+.fuzzy-hash-actions .btn-xs {
+    padding: 1px 0.3rem;
+    font-size: 0.75rem;
+    line-height: 1;
+}
+.fuzzy-hash-actions .btn-xs svg {
+    height: 0.65rem;
+    vertical-align: baseline;
+}
 
 .search-syntax-icon {
     position: absolute;
index 42980237d6180e8c4e509e868ee743286967ab59..dc032acde9a566b4b81394839cff57e6db39faf7 100644 (file)
@@ -184,6 +184,7 @@ define(["jquery", "app/common", "app/libft", "footable"],
                                     () => {
                                         $("#history .ft-columns-dropdown .btn-dropdown-apply").removeAttr("disabled");
                                         ui.updateHistoryControlsState();
+                                        if (version) libft.bindFuzzyHashButtons("history");
                                     });
                             });
                         }
index b24e0d466f1e8dddef0a41e4f4953c11901cbb79..26f053cc042d5ce578453ecaae0d82aeda1eb766 100644 (file)
@@ -548,6 +548,69 @@ define(["jquery", "app/common", "footable"],
                 : 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 idx = fuzzyHashIndex[shortHash];
+                    if (typeof idx !== "undefined") foundIndices.add(idx);
+                }
+            });
+
+            sym.fuzzyHashIndices = Array.from(foundIndices).sort((a, b) => a - b);
+        }
+
+        function generateFuzzySearchData(sym, fuzzyHashesArray) {
+            if (!sym.fuzzyHashIndices?.length) return "";
+
+            const fullHashes = sym.fuzzyHashIndices.map((i) => fuzzyHashesArray[i]);
+            return `<span class="visually-hidden">${common.escapeHTML(fullHashes.join(" "))}</span>`;
+        }
+
+        function generateFuzzyActions(sym, symbolName, 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;
@@ -595,6 +658,16 @@ define(["jquery", "app/common", "footable"],
                     }
 
                     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);
+                            fuzzyHashIndex[shortHash] = 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">
@@ -605,6 +678,12 @@ define(["jquery", "app/common", "footable"],
 
                         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, sym.name, table, item);
+                            }
                         }
                     });
                     unsorted_symbols.push(item.symbols);
@@ -647,5 +726,46 @@ define(["jquery", "app/common", "footable"],
             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();
+
+                    const indices = JSON.parse($(this).attr("data-indices") || "[]");
+                    const hashes = JSON.parse($(this).attr("data-hashes") || "[]");
+
+                    if (indices.length === 0 || hashes.length === 0) {
+                        common.alertMessage("alert-warning", "No full hashes available");
+                        return;
+                    }
+
+                    const fullHashes = 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;
     });
index 2bb85cd08bcbecde9cb3b3140639bb66e397c456..b3b463986e44c1f6f154e33163dc685366ec6ddf 100644 (file)
@@ -64,6 +64,16 @@ define(["jquery", "app/common", "app/libft"],
                 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");
 
@@ -93,6 +103,7 @@ define(["jquery", "app/common", "app/libft"],
                                             enable_disable_scan_btn();
                                             $("#cleanScanHistory, #scan .ft-columns-dropdown .btn-dropdown-apply")
                                                 .removeAttr("disabled");
+                                            libft.bindFuzzyHashButtons("scan");
                                             $("html, body").animate({
                                                 scrollTop: $("#scanResult").offset().top
                                             }, 1000);