Files
Bjorn/web/js/pages/web-enum.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

802 lines
28 KiB
JavaScript

/**
* Web Enum page module.
* Displays web enumeration/directory brute-force results with filtering,
* sorting, pagination, detail modal, and JSON/CSV export.
* Endpoint: GET /api/webenum/results?page=N&limit=M
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'web-enum';
const MAX_PAGES_FETCH = 200;
const FETCH_LIMIT = 500;
const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500, 0]; // 0 = All
const ANSI_RE = /[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g;
/* ── state ── */
let tracker = null;
let allData = [];
let filteredData = [];
let currentPage = 1;
let itemsPerPage = 50;
let sortField = 'scan_date';
let sortDirection = 'desc';
let exactStatusFilter = null;
let serverTotal = 0;
let fetchedLimit = false;
/* filter state */
let searchText = '';
let filterHost = '';
let filterStatusFamily = '';
let filterPort = '';
let filterDate = '';
let searchDebounceId = null;
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
container.appendChild(buildShell());
await fetchAllData();
}
export function unmount() {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
searchDebounceId = null;
if (tracker) { tracker.cleanupAll(); tracker = null; }
allData = [];
filteredData = [];
currentPage = 1;
itemsPerPage = 50;
sortField = 'scan_date';
sortDirection = 'desc';
exactStatusFilter = null;
serverTotal = 0;
fetchedLimit = false;
searchText = '';
filterHost = '';
filterStatusFamily = '';
filterPort = '';
filterDate = '';
}
/* ══════════════════════════════════════════════════════════════
Shell
══════════════════════════════════════════════════════════════ */
function buildShell() {
return el('div', { class: 'webenum-container' }, [
/* stats bar */
el('div', { class: 'stats-bar', id: 'we-stats' }, [
statItem('we-stat-total', 'Total Results'),
statItem('we-stat-hosts', 'Unique Hosts'),
statItem('we-stat-success', 'Success (2xx)'),
statItem('we-stat-errors', 'Errors (4xx/5xx)'),
]),
/* controls row */
el('div', { class: 'webenum-controls' }, [
/* text search */
el('div', { class: 'global-search-container' }, [
el('input', {
type: 'text', class: 'global-search-input', id: 'we-search',
placeholder: t('common.search') || 'Search host, IP, directory, status\u2026',
oninput: onSearchInput,
}),
el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']),
]),
el('div', { class: 'webenum-main-actions' }, [
el('button', { class: 'vuln-btn', onclick: () => fetchAllData() }, ['Refresh']),
]),
/* dropdown filters */
el('div', { class: 'webenum-filters' }, [
buildSelect('we-filter-host', 'All Hosts', onHostFilter),
buildSelect('we-filter-status', 'All Status', onStatusFamilyFilter),
buildSelect('we-filter-port', 'All Ports', onPortFilter),
el('input', {
type: 'date', class: 'webenum-date-input', id: 'we-filter-date',
onchange: onDateFilter,
}),
]),
/* export buttons */
el('div', { class: 'webenum-export-btns' }, [
el('button', { class: 'vuln-btn', onclick: () => exportData('json') }, ['Export JSON']),
el('button', { class: 'vuln-btn', onclick: () => exportData('csv') }, ['Export CSV']),
]),
]),
/* status legend chips */
el('div', { class: 'webenum-status-legend', id: 'we-status-legend' }),
/* table container */
el('div', { class: 'webenum-table-wrap', id: 'we-table-wrap' }),
/* pagination */
el('div', { class: 'webenum-pagination', id: 'we-pagination' }),
/* detail modal */
el('div', { class: 'vuln-modal', id: 'we-modal', onclick: onModalBackdrop }, [
el('div', { class: 'vuln-modal-content' }, [
el('div', { class: 'vuln-modal-header' }, [
el('span', { class: 'vuln-modal-title', id: 'we-modal-title' }),
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
]),
el('div', { class: 'vuln-modal-body', id: 'we-modal-body' }),
]),
]),
]);
}
function statItem(id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function buildSelect(id, defaultLabel, handler) {
return el('select', { class: 'webenum-filter-select', id, onchange: handler }, [
el('option', { value: '' }, [defaultLabel]),
]);
}
/* ══════════════════════════════════════════════════════════════
Data fetching — paginate through all server pages
══════════════════════════════════════════════════════════════ */
async function fetchAllData() {
const loading = $('#we-table-wrap');
if (loading) {
empty(loading);
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026']));
}
const ac = tracker ? tracker.trackAbortController() : null;
const signal = ac ? ac.signal : undefined;
let accumulated = [];
let page = 1;
serverTotal = 0;
fetchedLimit = false;
try {
while (page <= MAX_PAGES_FETCH) {
const url = `/api/webenum/results?page=${page}&limit=${FETCH_LIMIT}`;
const data = await api.get(url, { signal, timeout: 15000 });
const results = Array.isArray(data.results) ? data.results : [];
if (data.total != null) serverTotal = data.total;
if (results.length === 0) break;
accumulated = accumulated.concat(results);
/* all fetched */
if (serverTotal > 0 && accumulated.length >= serverTotal) break;
/* page was not full — last page */
if (results.length < FETCH_LIMIT) break;
page++;
}
if (page > MAX_PAGES_FETCH) fetchedLimit = true;
} catch (err) {
if (err.name === 'ApiError' && err.message === 'Aborted') return;
console.warn(`[${PAGE}] fetch error:`, err.message);
} finally {
if (ac && tracker) tracker.removeAbortController(ac);
}
allData = accumulated.map(normalizeRow);
populateFilterDropdowns();
applyFilters();
}
/* ── row normalization ── */
function normalizeRow(row) {
const host = (row.host || row.hostname || '').toString();
let directory = (row.directory || '').toString().replace(ANSI_RE, '');
return {
id: row.id,
host: host,
ip: (row.ip || '').toString(),
mac: (row.mac || '').toString(),
port: row.port != null ? Number(row.port) : 0,
directory: directory,
status: row.status != null ? Number(row.status) : 0,
size: row.size != null ? Number(row.size) : 0,
scan_date: row.scan_date || '',
response_time: row.response_time != null ? Number(row.response_time) : 0,
content_type: (row.content_type || '').toString(),
};
}
/* ══════════════════════════════════════════════════════════════
Filter dropdowns — populate from unique values
══════════════════════════════════════════════════════════════ */
function populateFilterDropdowns() {
populateSelect('we-filter-host', 'All Hosts',
[...new Set(allData.map(r => r.host).filter(Boolean))].sort());
const families = [...new Set(allData.map(r => statusFamily(r.status)).filter(Boolean))].sort();
populateSelect('we-filter-status', 'All Status', families);
const ports = [...new Set(allData.map(r => r.port).filter(p => p > 0))].sort((a, b) => a - b);
populateSelect('we-filter-port', 'All Ports', ports.map(String));
}
function populateSelect(id, defaultLabel, options) {
const sel = $(`#${id}`);
if (!sel) return;
const current = sel.value;
empty(sel);
sel.appendChild(el('option', { value: '' }, [defaultLabel]));
options.forEach(opt => {
sel.appendChild(el('option', { value: opt }, [opt]));
});
if (current && options.includes(current)) sel.value = current;
}
/* ══════════════════════════════════════════════════════════════
Filter & sort pipeline
══════════════════════════════════════════════════════════════ */
function applyFilters() {
const needle = searchText.toLowerCase();
filteredData = allData.filter(row => {
/* exact status chip filter */
if (exactStatusFilter != null && row.status !== exactStatusFilter) return false;
/* text search */
if (needle) {
const hay = `${row.host} ${row.ip} ${row.directory} ${row.status}`.toLowerCase();
if (!hay.includes(needle)) return false;
}
/* host dropdown */
if (filterHost && row.host !== filterHost) return false;
/* status family dropdown */
if (filterStatusFamily && statusFamily(row.status) !== filterStatusFamily) return false;
/* port dropdown */
if (filterPort && String(row.port) !== filterPort) return false;
/* date filter */
if (filterDate) {
const rowDate = (row.scan_date || '').substring(0, 10);
if (rowDate !== filterDate) return false;
}
return true;
});
applySort();
currentPage = 1;
updateStats();
renderStatusLegend();
renderTable();
renderPagination();
}
function applySort() {
const dir = sortDirection === 'asc' ? 1 : -1;
const field = sortField;
filteredData.sort((a, b) => {
let va = a[field];
let vb = b[field];
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') {
return (va - vb) * dir;
}
/* date string comparison */
if (field === 'scan_date') {
const da = new Date(va).getTime() || 0;
const db = new Date(vb).getTime() || 0;
return (da - db) * dir;
}
return String(va).localeCompare(String(vb)) * dir;
});
}
/* ══════════════════════════════════════════════════════════════
Stats bar
══════════════════════════════════════════════════════════════ */
function updateStats() {
const totalLabel = fetchedLimit
? `${filteredData.length} (truncated)`
: String(filteredData.length);
setStatVal('we-stat-total', totalLabel);
setStatVal('we-stat-hosts', new Set(filteredData.map(r => r.host || r.ip)).size);
setStatVal('we-stat-success', filteredData.filter(r => r.status >= 200 && r.status < 300).length);
setStatVal('we-stat-errors', filteredData.filter(r => r.status >= 400).length);
}
function setStatVal(id, val) {
const e = $(`#${id}`);
if (e) e.textContent = String(val);
}
/* ══════════════════════════════════════════════════════════════
Status legend chips
══════════════════════════════════════════════════════════════ */
function renderStatusLegend() {
const container = $('#we-status-legend');
if (!container) return;
empty(container);
/* gather unique status codes from current allData (unfiltered view) */
const codes = [...new Set(allData.map(r => r.status))].sort((a, b) => a - b);
if (codes.length === 0) return;
codes.forEach(code => {
const count = allData.filter(r => r.status === code).length;
const isActive = exactStatusFilter === code;
const chip = el('span', {
class: `webenum-status-chip ${statusClass(code)} ${isActive ? 'active' : ''}`,
onclick: () => {
if (exactStatusFilter === code) {
exactStatusFilter = null;
} else {
exactStatusFilter = code;
}
/* clear active class on all chips, re-apply via full filter cycle */
$$('.webenum-status-chip', container).forEach(c => c.classList.remove('active'));
applyFilters();
},
}, [`${code} (${count})`]);
container.appendChild(chip);
});
}
/* ══════════════════════════════════════════════════════════════
Table rendering
══════════════════════════════════════════════════════════════ */
function renderTable() {
const wrap = $('#we-table-wrap');
if (!wrap) return;
empty(wrap);
if (filteredData.length === 0) {
wrap.appendChild(emptyState('No web enumeration results found'));
return;
}
/* current page slice */
const pageData = getPageSlice();
/* column definitions */
const columns = [
{ key: 'host', label: 'Host' },
{ key: 'ip', label: 'IP' },
{ key: 'port', label: 'Port' },
{ key: 'directory', label: 'Directory' },
{ key: 'status', label: 'Status' },
{ key: 'size', label: 'Size' },
{ key: 'scan_date', label: 'Scan Date' },
{ key: '_actions', label: 'Actions' },
];
/* thead */
const headerCells = columns.map(col => {
if (col.key === '_actions') {
return el('th', {}, [col.label]);
}
const isSorted = sortField === col.key;
const arrow = isSorted ? (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC') : '';
return el('th', {
class: `sortable ${isSorted ? 'sort-' + sortDirection : ''}`,
style: 'cursor:pointer;user-select:none;',
onclick: () => onSortColumn(col.key),
}, [col.label + arrow]);
});
const thead = el('thead', {}, [el('tr', {}, headerCells)]);
/* tbody */
const rows = pageData.map(row => {
const url = buildUrl(row);
return el('tr', {
class: 'webenum-row',
style: 'cursor:pointer;',
onclick: (e) => {
/* ignore if click was on an anchor */
if (e.target.tagName === 'A') return;
showDetailModal(row);
},
}, [
el('td', {}, [row.host || '-']),
el('td', {}, [row.ip || '-']),
el('td', {}, [row.port ? String(row.port) : '-']),
el('td', { class: 'webenum-dir-cell', title: row.directory }, [row.directory || '/']),
el('td', {}, [statusBadge(row.status)]),
el('td', {}, [formatSize(row.size)]),
el('td', {}, [formatDate(row.scan_date)]),
el('td', {}, [
url
? el('a', {
href: url, target: '_blank', rel: 'noopener noreferrer',
class: 'webenum-link', title: url,
onclick: (e) => e.stopPropagation(),
}, ['Open'])
: el('span', { class: 'muted' }, ['-']),
]),
]);
});
const tbody = el('tbody', {}, rows);
const table = el('table', { class: 'webenum-table' }, [thead, tbody]);
wrap.appendChild(el('div', { class: 'table-inner' }, [table]));
}
function getPageSlice() {
if (itemsPerPage === 0) return filteredData; // All
const start = (currentPage - 1) * itemsPerPage;
return filteredData.slice(start, start + itemsPerPage);
}
function getTotalPages() {
if (itemsPerPage === 0) return 1;
return Math.max(1, Math.ceil(filteredData.length / itemsPerPage));
}
/* ══════════════════════════════════════════════════════════════
Pagination
══════════════════════════════════════════════════════════════ */
function renderPagination() {
const pag = $('#we-pagination');
if (!pag) return;
empty(pag);
const total = getTotalPages();
/* per-page selector */
const perPageSel = el('select', { class: 'webenum-filter-select webenum-perpage', onchange: onPerPageChange }, []);
PER_PAGE_OPTIONS.forEach(n => {
const label = n === 0 ? 'All' : String(n);
const opt = el('option', { value: String(n) }, [label]);
if (n === itemsPerPage) opt.selected = true;
perPageSel.appendChild(opt);
});
pag.appendChild(el('div', { class: 'webenum-perpage-wrap' }, [
el('span', { class: 'stat-label' }, ['Per page:']),
perPageSel,
]));
if (total <= 1 && itemsPerPage !== 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
]));
return;
}
if (itemsPerPage === 0) {
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
]));
return;
}
/* Prev */
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
/* numbered buttons */
const start = Math.max(1, currentPage - 2);
const end = Math.min(total, start + 4);
for (let i = start; i <= end; i++) {
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
}
/* Next */
pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1)));
/* info */
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
`Page ${currentPage} of ${total} (${filteredData.length} results)`,
]));
}
function pageBtn(label, enabled, onclick, active = false) {
return el('button', {
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
onclick: enabled ? onclick : null,
disabled: !enabled,
}, [label]);
}
function changePage(p) {
const total = getTotalPages();
currentPage = Math.max(1, Math.min(total, p));
renderTable();
renderPagination();
const wrap = $('#we-table-wrap');
if (wrap) wrap.scrollTop = 0;
}
function onPerPageChange(e) {
itemsPerPage = parseInt(e.target.value, 10);
currentPage = 1;
renderTable();
renderPagination();
}
/* ══════════════════════════════════════════════════════════════
Sort handler
══════════════════════════════════════════════════════════════ */
function onSortColumn(key) {
if (sortField === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = key;
sortDirection = 'asc';
}
applySort();
renderTable();
renderPagination();
}
/* ══════════════════════════════════════════════════════════════
Filter handlers
══════════════════════════════════════════════════════════════ */
function onSearchInput(e) {
if (searchDebounceId != null) clearTimeout(searchDebounceId);
const val = e.target.value;
searchDebounceId = tracker
? tracker.trackTimeout(() => {
searchText = val;
applyFilters();
const btn = e.target.nextElementSibling;
if (btn) btn.classList.toggle('show', val.length > 0);
}, 300)
: setTimeout(() => {
searchText = val;
applyFilters();
}, 300);
}
function clearSearch() {
const inp = $('#we-search');
if (inp) inp.value = '';
searchText = '';
applyFilters();
const btn = inp ? inp.nextElementSibling : null;
if (btn) btn.classList.remove('show');
}
function onHostFilter(e) {
filterHost = e.target.value;
applyFilters();
}
function onStatusFamilyFilter(e) {
filterStatusFamily = e.target.value;
/* clear exact chip filter when dropdown changes */
exactStatusFilter = null;
applyFilters();
}
function onPortFilter(e) {
filterPort = e.target.value;
applyFilters();
}
function onDateFilter(e) {
filterDate = e.target.value || '';
applyFilters();
}
/* ══════════════════════════════════════════════════════════════
Detail modal
══════════════════════════════════════════════════════════════ */
function showDetailModal(row) {
const modal = $('#we-modal');
const title = $('#we-modal-title');
const body = $('#we-modal-body');
if (!modal || !title || !body) return;
const url = buildUrl(row);
title.textContent = `${row.host || row.ip}${row.directory || '/'}`;
empty(body);
const fields = [
['Host', row.host],
['IP', row.ip],
['MAC', row.mac],
['Port', row.port],
['Directory', row.directory],
['Status', row.status],
['Size', formatSize(row.size)],
['Content-Type', row.content_type],
['Response Time', row.response_time ? row.response_time + ' ms' : '-'],
['Scan Date', formatDate(row.scan_date)],
['URL', url || 'N/A'],
];
fields.forEach(([label, value]) => {
body.appendChild(el('div', { class: 'modal-detail-section' }, [
el('div', { class: 'modal-section-title' }, [label]),
el('div', { class: 'modal-section-text' }, [
label === 'Status'
? statusBadge(value)
: String(value != null ? value : '-'),
]),
]));
});
/* action buttons */
const actions = el('div', { class: 'webenum-modal-actions' }, []);
if (url) {
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
window.open(url, '_blank', 'noopener,noreferrer');
}}, ['Open URL']));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
copyText(url);
}}, ['Copy URL']));
}
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'json') }, ['Export JSON']));
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'csv') }, ['Export CSV']));
body.appendChild(actions);
modal.classList.add('show');
}
function closeModal() {
const modal = $('#we-modal');
if (modal) modal.classList.remove('show');
}
function onModalBackdrop(e) {
if (e.target.classList.contains('vuln-modal')) closeModal();
}
/* ══════════════════════════════════════════════════════════════
Export — JSON & CSV
══════════════════════════════════════════════════════════════ */
function exportData(format) {
const data = filteredData.length > 0 ? filteredData : allData;
if (data.length === 0) return;
const dateStr = new Date().toISOString().split('T')[0];
if (format === 'json') {
const json = JSON.stringify(data, null, 2);
downloadBlob(json, `webenum_results_${dateStr}.json`, 'application/json');
} else {
const csv = buildCSV(data);
downloadBlob(csv, `webenum_results_${dateStr}.csv`, 'text/csv');
}
}
function exportSingleResult(row, format) {
const dateStr = new Date().toISOString().split('T')[0];
if (format === 'json') {
downloadBlob(JSON.stringify(row, null, 2), `webenum_${row.host}_${dateStr}.json`, 'application/json');
} else {
downloadBlob(buildCSV([row]), `webenum_${row.host}_${dateStr}.csv`, 'text/csv');
}
}
function buildCSV(data) {
const headers = ['Host', 'IP', 'MAC', 'Port', 'Directory', 'Status', 'Size', 'Content-Type', 'Response Time', 'Scan Date', 'URL'];
const rows = [headers.join(',')];
data.forEach(r => {
const url = buildUrl(r) || '';
const values = [
r.host, r.ip, r.mac, r.port, r.directory, r.status,
r.size, r.content_type, r.response_time, r.scan_date, url,
].map(v => {
const s = String(v != null ? v : '');
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"` : s;
});
rows.push(values.join(','));
});
return rows.join('\n');
}
function downloadBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* ══════════════════════════════════════════════════════════════
Helpers
══════════════════════════════════════════════════════════════ */
/** Status family string: '2xx', '3xx', '4xx', '5xx' */
function statusFamily(code) {
code = Number(code) || 0;
if (code >= 200 && code < 300) return '2xx';
if (code >= 300 && code < 400) return '3xx';
if (code >= 400 && code < 500) return '4xx';
if (code >= 500) return '5xx';
return '';
}
/** CSS class for status code */
function statusClass(code) {
code = Number(code) || 0;
if (code >= 200 && code < 300) return 'status-2xx';
if (code >= 300 && code < 400) return 'status-3xx';
if (code >= 400 && code < 500) return 'status-4xx';
if (code >= 500) return 'status-5xx';
return '';
}
/** Status badge element */
function statusBadge(code) {
return el('span', { class: `webenum-status-badge ${statusClass(code)}` }, [String(code)]);
}
/** Build full URL from row data */
function buildUrl(row) {
if (!row.host && !row.ip) return '';
const hostname = row.host || row.ip;
const port = Number(row.port) || 80;
const proto = port === 443 ? 'https' : 'http';
const portPart = (port === 80 || port === 443) ? '' : `:${port}`;
const dir = row.directory || '/';
return `${proto}://${hostname}${portPart}${dir}`;
}
/** Format byte size to human-readable */
function formatSize(bytes) {
bytes = Number(bytes) || 0;
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
/** Format date string */
function formatDate(d) {
if (!d) return '-';
try {
const date = new Date(d);
if (isNaN(date.getTime())) return String(d);
return date.toLocaleDateString();
} catch {
return String(d);
}
}
/** Empty state */
function emptyState(msg) {
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\uD83D\uDD0D']),
msg,
]);
}
/** Copy text to clipboard */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* noop */ }
document.body.removeChild(ta);
}