Files
Bjorn/web/web_enum.html

769 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Bjorn Cyberviking WebEnum</title>
<link rel="icon" href="/web/images/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/web/css/global.css" />
<link rel="manifest" href="manifest.json" />
<link rel="apple-touch-icon" sizes="192x192" href="/web/images/icon-192x192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#333" />
<script src="/web/js/global.js" defer></script>
<!-- Ponts légers pour mapper sur global.css sans changer la logique -->
<style>
/* Layout de page */
body{background:var(--grad-bg-1), var(--grad-bg-2), var(--bg); color:var(--ink); font:var(--font-mono)}
.container{max-width:1400px;margin:0 auto;padding:16px}
/* Header */
.header.card{display:flex;align-items:center;justify-content:space-between;gap:10px}
.header h1{margin:0;color:var(--acid)}
/* Cartes & blocs */
.controls.card{display:grid;gap:10px}
.controls-row{display:flex;flex-wrap:wrap;gap:var(--gap-3);align-items:center}
/* Champs */
.search-box{flex:1;min-width:230px;position:relative}
.search-box .input{width:100%;padding-right:36px}
.search-icon{position:absolute;right:10px;top:50%;transform:translateY(-50%);color:var(--acid)}
/* Stats */
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin:10px 0 12px}
.stat-card{background:var(--grad-card);border:1px solid var(--c-border);border-radius:14px;padding:12px 14px;box-shadow:var(--shadow)}
.stat-value{font-weight:700;color:var(--acid)}
.stat-label{color:var(--muted)}
/* Legend */
.status-legend.card{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
/* Tableau */
.results-container.card{overflow:hidden}
.results-header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--c-border);padding-bottom:8px;margin-bottom:8px}
.results-count{color:var(--accent-2);font-weight:600}
.table-container{overflow:auto;max-height:calc(100vh - 520px);min-height:400px}
table{width:100%;border-collapse:collapse}
th{position:sticky;top:0;z-index:1;background:var(--c-panel-2);color:var(--acid);text-align:left;padding:10px 12px;border-bottom:1px solid var(--c-border);user-select:none;cursor:pointer;font-weight:700}
td{padding:8px 12px;border-bottom:1px dashed var(--c-border)}
tr{transition:background .15s ease}
tr:hover{background:color-mix(in oklab, var(--acid) 8%, transparent);cursor:pointer}
th.sortable::after{content:' ⇅';opacity:.5}
th.sort-asc::after{content:' ↑';color:var(--acid);opacity:1}
th.sort-desc::after{content:' ↓';color:var(--acid);opacity:1}
.no-results{text-align:center;padding:40px;color:var(--muted);font-style:italic}
.loading{text-align:center;padding:40px;color:var(--acid)}
/* Badges & liens */
.host-badge{background:var(--c-chip-bg);color:var(--accent-2);padding:3px 8px;border-radius:8px;border:1px solid var(--c-border);font-weight:600;font-size:.9rem}
.port-badge{background:var(--c-chip-bg);color:var(--acid);padding:3px 8px;border-radius:8px;border:1px solid var(--c-border);font-weight:700;font-size:.9rem}
.url-link{color:var(--acid-2);text-decoration:none;font-size:1.1rem;transition:.2s}
.url-link:hover{color:var(--acid);transform:scale(1.2);display:inline-block}
/* Status chips */
.status{
display:inline-block;
min-width:60px;
text-align:center;
padding:5px 10px;
border-radius:8px;
font-weight:700;
font-size:.85rem;
border:1px solid var(--c-border);
transition:.2s;
cursor:default;
}
.status:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.3)}
.status-2xx{background:var(--ok);color:var(--ink-invert)}
.status-3xx{background:var(--warning);color:var(--ink-invert)}
.status-4xx{background:var(--danger);color:var(--ink)}
.status-5xx{background:color-mix(in oklab, var(--danger) 65%, var(--lvl-crit-bg) 35%);color:var(--ink)}
.status-unknown{background:var(--muted-off);color:var(--ink)}
/* Pagination */
.pagination{display:flex;justify-content:center;align-items:center;gap:8px;padding:10px;background:var(--c-panel);border-top:1px dashed var(--c-border)}
.page-btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 10px;border-radius:var(--control-r);background:var(--c-btn);border:1px solid var(--c-border-strong);color:var(--ink);cursor:pointer;box-shadow:var(--shadow);transition:.18s}
.page-btn:hover{transform:translateY(-1px);box-shadow:var(--shadow-hover)}
.page-btn.active{outline:2px solid color-mix(in oklab, var(--acid) 55%, transparent)}
.page-btn:disabled{opacity:.5;cursor:not-allowed}
/* Boutons */
.btn-primary{background:linear-gradient(180deg, color-mix(in oklab, var(--acid) 28%, var(--c-btn)), var(--c-btn));border-color:color-mix(in oklab, var(--acid) 45%, var(--c-border));color:var(--ink)}
/* Modal WebEnum */
.webenum-modal-backdrop{
display:none;
position:fixed;
inset:0;
background:rgba(0,0,0,0.85);
backdrop-filter:blur(4px);
z-index:9999;
align-items:center;
justify-content:center;
animation:fadeIn 0.2s ease;
}
.webenum-modal-backdrop.show{display:flex}
.webenum-modal-content{
background:var(--c-panel);
border:1px solid var(--c-border-strong);
border-radius:16px;
box-shadow:0 8px 32px rgba(0,0,0,0.5);
width:min(720px,96vw);
max-height:86vh;
overflow:auto;
padding:24px;
position:relative;
animation:slideUp 0.3s ease;
}
.webenum-modal-content h2{margin:0 0 16px;color:var(--acid);font-size:1.5rem}
.webenum-close{
position:absolute;
top:16px;
right:16px;
color:var(--muted);
font-size:28px;
font-weight:700;
cursor:pointer;
line-height:1;
transition:.2s;
background:var(--c-btn);
border:1px solid var(--c-border);
border-radius:8px;
width:32px;
height:32px;
display:flex;
align-items:center;
justify-content:center;
}
.webenum-close:hover{color:var(--acid);transform:rotate(90deg)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
/* Responsive */
@media (max-width:768px){
.container{padding:10px}
.results-header{flex-direction:column;gap:8px;text-align:center}
th,td{padding:8px 6px}
}
@media (max-width:480px){
th,td{padding:6px 4px;font-size:.85rem}
.status{font-size:.75rem}
}
</style>
</head>
<body>
<main class="main" id="main">
<div class="container">
<div class="header card">
<h1 class="card-title">🔍 WebEnum</h1>
</div>
<div class="stats" id="stats"></div>
<div class="controls card">
<div class="controls-row">
<div class="search-box">
<input id="searchInput" class="input" type="text" placeholder="Search directories, hosts, IPs, status codes..."/>
<span class="search-icon">🔍</span>
</div>
<button class="btn btn-primary" onclick="refreshData()">Refresh</button>
</div>
<div class="controls-row">
<div class="filter-group chips">
<select id="hostFilter" class="select"><option value="">All Hosts</option></select>
<select id="statusFilter" class="select" title="Filter by status family">
<option value="">All Status (families)</option>
<option value="2xx">2xx Success</option>
<option value="3xx">3xx Redirect</option>
<option value="4xx">4xx Client Error</option>
<option value="5xx">5xx Server Error</option>
</select>
<select id="portFilter" class="select"><option value="">All Ports</option></select>
<input type="date" id="dateFilter" class="input" title="Filter by scan date"/>
</div>
<div class="export-controls chips">
<button class="btn" onclick="exportResults('json')">Export JSON</button>
<button class="btn" onclick="exportResults('csv')">Export CSV</button>
</div>
</div>
</div>
<!-- Legend dynamique (remplie par JS) -->
<div class="status-legend card" id="statusLegend"></div>
<div class="results-container card">
<div class="results-header">
<div class="results-count" id="resultsCount">Loading results...</div>
<div>
<label for="itemsPerPage">Items per page:</label>
<select id="itemsPerPage" class="select" onchange="changeItemsPerPage()">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
<option value="all">All</option>
</select>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable" onclick="sortTable('host')">Host</th>
<th class="sortable" onclick="sortTable('ip')">IP</th>
<th class="sortable" onclick="sortTable('port')">Port</th>
<th class="sortable" onclick="sortTable('directory')">Directory</th>
<th class="sortable" onclick="sortTable('status')">Status</th>
<th class="sortable" onclick="sortTable('size')">Size</th>
<th class="sortable" onclick="sortTable('scan_date')">Scan Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="resultsTable"></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
</div>
<!-- Modal WebEnum -->
<div id="webenumDetailModal" class="webenum-modal-backdrop">
<div class="webenum-modal-content">
<span class="webenum-close" onclick="closeWebenumModal()">&times;</span>
<div id="webenumModalContent"></div>
</div>
</div>
</main>
<!-- JS avec correctif d'IDs du modal -->
<script>
// =================== Globals ===================
let allData = [];
let filteredData = [];
let currentPage = 1;
let itemsPerPage = 50;
let sortField = 'scan_date';
let sortDirection = 'desc';
let serverTotal = 0;
let fetchedLimit = 0;
let exactStatusFilter = null; // filtre par code exact (chips)
// API
const API_BASE_URL = '/api/webenum';
const RESULTS_ENDPOINT = `${API_BASE_URL}/results`;
const PAGE_LIMIT = 500;
const MAX_PAGES = 200;
document.addEventListener('DOMContentLoaded', () => {
loadData();
setupEventListeners();
});
function setupEventListeners(){
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', ()=>{
clearTimeout(searchTimeout);
searchTimeout = setTimeout(filterData, 300);
});
['hostFilter','statusFilter','portFilter','dateFilter'].forEach(id=>{
document.getElementById(id).addEventListener('change', ()=>{
if (id === 'statusFilter') exactStatusFilter = null; // si on change la famille, on annule l'exact
filterData();
});
});
document.addEventListener('keydown', e=>{
if (e.ctrlKey || e.metaKey){
if (e.key==='f'){ e.preventDefault(); document.getElementById('searchInput').focus(); }
if (e.key==='r'){ e.preventDefault(); refreshData(); }
}
});
}
// =================== Fetch helpers ===================
async function fetchPage(page=1,limit=PAGE_LIMIT){
const url = `${RESULTS_ENDPOINT}?page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`;
const res = await fetch(url);
if(!res.ok) throw new Error(`HTTP error ${res.status}`);
const json = await res.json();
return {
results: Array.isArray(json.results) ? json.results : (Array.isArray(json) ? json : []),
total: Number.isFinite(json.total) ? json.total : (Array.isArray(json.results) ? json.results.length : 0),
page: json.page ?? page, limit: json.limit ?? limit
};
}
async function fetchAll(limitPerPage=PAGE_LIMIT){
let page=1, total=Infinity; const acc=[];
while((page-1)*limitPerPage<total && page<=MAX_PAGES){
const {results,total:t}=await fetchPage(page,limitPerPage);
if(!results || results.length===0) break;
acc.push(...results);
total = Number.isFinite(t) && t>0 ? t : acc.length;
page++;
}
return {results:acc,total};
}
// =================== Load ===================
async function loadData(){
showLoading();
try{
const {results,total}=await fetchAll(PAGE_LIMIT);
allData = results;
serverTotal = total || results.length;
fetchedLimit = allData.length;
populateFilters();
renderStatusLegend();
filterData();
updateStats();
}catch(e){
console.error(e);
showError('Failed to load data. Using sample data for demo.');
allData = generateSampleData();
serverTotal = fetchedLimit = allData.length;
populateFilters();
renderStatusLegend();
filterData();
updateStats();
}
}
// =================== Sample ===================
function generateSampleData(){
const hosts=['example.com','test.org','demo.net','site.io'];
const ips=['192.168.1.10','10.0.0.5','172.16.1.20','203.0.113.45'];
const ports=[80,443,8080,8443,3000];
const dirs=['/admin','/api','/backup','/config','/test','/dev','/upload','/files','/images','/scripts'];
const statuses=[200,201,204,301,302,401,403,404,500,502,503];
const out=[];
for(let i=0;i<150;i++){
out.push({
id:i+1,
host:hosts[Math.floor(Math.random()*hosts.length)],
ip:ips[Math.floor(Math.random()*ips.length)],
mac:generateRandomMAC(),
port:ports[Math.floor(Math.random()*ports.length)],
directory:dirs[Math.floor(Math.random()*dirs.length)],
status:statuses[Math.floor(Math.random()*statuses.length)],
size:Math.floor(Math.random()*50000)+100,
scan_date:new Date(Date.now()-Math.random()*30*24*60*60*1000).toISOString(),
response_time:Math.floor(Math.random()*2000)+50,
content_type:['text/html','application/json','text/plain','image/jpeg'][Math.floor(Math.random()*4)]
});
}
return out;
}
// =================== Utils ===================
function getStatusText(code){
const map={
200:"OK — Request succeeded",
201:"Created — Resource created",
204:"No Content — Success without a body",
301:"Moved Permanently — Redirect",
302:"Found — Temporary redirect",
303:"See Other — Redirect to another endpoint",
307:"Temporary Redirect — Same method",
308:"Permanent Redirect — Same method",
401:"Unauthorized — Auth required",
403:"Forbidden — Access denied",
404:"Not Found — No such resource",
405:"Method Not Allowed — Wrong HTTP verb",
500:"Internal Server Error — Server issue",
502:"Bad Gateway — Upstream issue",
503:"Service Unavailable — Overloaded or down",
504:"Gateway Timeout — Upstream didn't answer"
};
if(map[code]) return map[code];
if(code>=200 && code<300) return "Success — 2xx";
if(code>=300 && code<400) return "Redirect — 3xx";
if(code>=400 && code<500) return "Client Error — 4xx";
if(code>=500) return "Server Error — 5xx";
return "Unknown status";
}
function generateRandomMAC(){return Array.from({length:6},()=>Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(':').toUpperCase()}
function sanitizePath(p){
const s=String(p||'/'); const ANSI=/\x1B\[[0-?]*[ -/]*[@-~]/g; const CTL=/[\x00-\x1F\x7F]/g;
return s.replace(ANSI,'').replace(CTL,'');
}
function getStatusClass(status){
if(status>=200 && status<300) return 'status-2xx';
if(status>=300 && status<400) return 'status-3xx';
if(status>=400 && status<500) return 'status-4xx';
if(status>=500) return 'status-5xx';
return 'status-unknown';
}
// =================== Filters & legend ===================
function populateFilters(){
const hosts=[...new Set(allData.map(i=>i.hostname||i.host).filter(Boolean))].sort();
const ports=[...new Set(allData.map(i=>i.port).filter(v=>v!==undefined))].sort((a,b)=>a-b);
populateSelectOptions('hostFilter',hosts);
populateSelectOptions('portFilter',ports);
}
function populateSelectOptions(selectId,options){
const select=document.getElementById(selectId);
const current=select.value;
select.innerHTML=select.children[0]?.outerHTML || '<option value="">All</option>';
options.forEach(opt=>{
const el=document.createElement('option'); el.value=opt; el.textContent=opt; select.appendChild(el);
});
select.value=current;
}
function renderStatusLegend(){
const legend = document.getElementById('statusLegend');
const codes = [...new Set(allData.map(r=>r.status).filter(x=>x!=null))].sort((a,b)=>a-b);
const frag = document.createDocumentFragment();
// Chip ALL
const chipAll = makeStatusChip('All', null, 'Show all status codes');
frag.appendChild(chipAll);
// Chips par code existant
codes.forEach(code=>{
const chip = makeStatusChip(String(code), code, `${code}${getStatusText(code)}`);
frag.appendChild(chip);
});
legend.innerHTML='';
legend.appendChild(frag);
highlightActiveChip();
}
function makeStatusChip(label, code, title){
const chip=document.createElement('button');
chip.className='chip';
chip.type='button';
chip.title=title || '';
chip.setAttribute('data-code', code==null ? 'ALL' : String(code));
chip.addEventListener('click', (e)=>{
e.preventDefault();
e.stopPropagation();
setExactStatusFilter(code);
});
const badge=document.createElement('span');
const cls = code==null ? 'status status-unknown' : `status ${getStatusClass(code)} status-${code}`;
badge.className=cls;
badge.textContent = code==null ? 'All' : String(code);
chip.appendChild(badge);
return chip;
}
function setExactStatusFilter(code){
exactStatusFilter = (code==null) ? null : Number(code);
document.getElementById('statusFilter').value = '';
filterData();
highlightActiveChip();
}
function highlightActiveChip(){
const chips = document.querySelectorAll('#statusLegend .chip');
chips.forEach(c=>c.classList.remove('active'));
const target = document.querySelector(`#statusLegend .chip[data-code="${exactStatusFilter==null?'ALL':String(exactStatusFilter)}"]`);
if(target) target.classList.add('active');
}
// =================== Core filtering ===================
function filterData(){
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const hostFilter = document.getElementById('hostFilter').value;
const familyFilter = document.getElementById('statusFilter').value; // 2xx/3xx/...
const portFilter = document.getElementById('portFilter').value;
const dateFilter = document.getElementById('dateFilter').value;
filteredData = allData.map(normalizeRow).filter(item=>{
const matchesSearch = !searchTerm ||
(item.host && item.host.toLowerCase().includes(searchTerm)) ||
(item.ip && item.ip.includes(searchTerm)) ||
(item.directory && item.directory.toLowerCase().includes(searchTerm)) ||
String(item.status||'').includes(searchTerm);
const matchesHost = !hostFilter || item.host === hostFilter;
// priorité au filtre exact via chip
const matchesExact = (exactStatusFilter==null) || item.status === exactStatusFilter;
// puis filtre famille si pas de chip
const matchesFamily = (!familyFilter || exactStatusFilter!=null)
? true
: getStatusClass(item.status).includes(familyFilter);
const matchesPort = !portFilter || String(item.port) === portFilter;
const matchesDate = !dateFilter || (item.scan_date && item.scan_date.startsWith(dateFilter));
return matchesSearch && matchesHost && matchesExact && matchesFamily && matchesPort && matchesDate;
});
sortData();
currentPage=1;
displayResults();
updatePagination();
}
function normalizeRow(row){
const host=row.hostname||row.host||'';
const iso=row.scan_date && !isNaN(Date.parse(row.scan_date))
? new Date(row.scan_date).toISOString()
: (row.scan_date||null);
return {...row, host, directory:sanitizePath(row.directory||'/'), scan_date:iso};
}
// =================== Sorting ===================
function sortData(){
filteredData.sort((a,b)=>{
let aVal=a[sortField], bVal=b[sortField];
if(sortField==='scan_date'){ aVal=aVal?Date.parse(aVal):0; bVal=bVal?Date.parse(bVal):0; }
else{
if(typeof aVal==='string') aVal=aVal.toLowerCase();
if(typeof bVal==='string') bVal=bVal.toLowerCase();
}
return sortDirection==='asc' ? (aVal>bVal?1:-1) : (aVal<bVal?1:-1);
});
}
function sortTable(field){
if(sortField===field) sortDirection = (sortDirection==='asc'?'desc':'asc');
else { sortField=field; sortDirection='asc'; }
document.querySelectorAll('th').forEach(th=>th.className=th.className.replace(/sort-(asc|desc)/,''));
const th=document.querySelector(`th[onclick="sortTable('${field}')"]`);
if(th) th.classList.add(`sort-${sortDirection}`);
filterData();
}
// =================== Rendering ===================
function displayResults(){
const tbody=document.getElementById('resultsTable');
const startIndex=(currentPage-1)*itemsPerPage;
const endIndex=startIndex+itemsPerPage;
const pageData=filteredData.slice(startIndex,endIndex);
if(pageData.length===0){
tbody.innerHTML='<tr><td colspan="8" class="no-results">No results found</td></tr>';
document.getElementById('resultsCount').textContent='Showing 0-0 of 0 results';
return;
}
tbody.innerHTML = pageData.map(item=>`
<tr onclick="showDetails(${item.id})">
<td><span class="host-badge">${escapeHtml(item.host||'')}</span></td>
<td>${escapeHtml(item.ip||'')}</td>
<td><span class="port-badge">${item.port ?? ''}</span></td>
<td>${escapeHtml(item.directory||'/')}</td>
<td>
<span class="status ${getStatusClass(item.status)} status-${item.status}"
title="${item.status}${getStatusText(item.status)}">
${item.status}
</span>
</td>
<td>${formatSize(Number(item.size||0))}</td>
<td>${formatDate(item.scan_date)}</td>
<td>
<a href="${buildUrl(item)}" target="_blank" class="url-link" onclick="event.stopPropagation()" title="Open URL">🔗</a>
</td>
</tr>
`).join('');
document.getElementById('resultsCount').textContent =
`Showing ${startIndex+1}-${Math.min(endIndex,filteredData.length)} of ${filteredData.length} results`;
}
function buildUrl(item){
const protocol=item.port===443?'https':'http';
const port=(item.port===80||item.port===443)?'':`:${item.port}`;
const host=item.ip||item.host||'';
const cleanDir='/' + String(sanitizePath(item.directory||'/')).replace(/^\/*/,'');
return `${protocol}://${host}${port}${cleanDir}`;
}
function formatSize(size){
if(!Number.isFinite(size)||size<0) return 'N/A';
if(size<1024) return size+' B';
if(size<1024*1024) return (size/1024).toFixed(1)+' KB';
return (size/(1024*1024)).toFixed(1)+' MB';
}
function formatDate(s){
if(!s) return 'N/A'; const d=new Date(s); return isNaN(d)?'N/A':d.toLocaleDateString();
}
function escapeHtml(t){const div=document.createElement('div');div.textContent=t==null?'':String(t);return div.innerHTML;}
// =================== Pagination ===================
function updatePagination(){
const pagination=document.getElementById('pagination');
const totalPages=Math.ceil(filteredData.length/itemsPerPage);
if(itemsPerPage>=filteredData.length || totalPages<=1){ pagination.innerHTML=''; return; }
let html=`
<button class="page-btn" ${currentPage===1?'disabled':''} onclick="goToPage(1)">First</button>
<button class="page-btn" ${currentPage===1?'disabled':''} onclick="goToPage(${currentPage-1})">Previous</button>`;
const startPage=Math.max(1,currentPage-2);
const endPage=Math.min(totalPages,currentPage+2);
for(let i=startPage;i<=endPage;i++){
html+=`<button class="page-btn ${i===currentPage?'active':''}" onclick="goToPage(${i})">${i}</button>`;
}
html+=`
<button class="page-btn" ${currentPage===totalPages?'disabled':''} onclick="goToPage(${currentPage+1})">Next</button>
<button class="page-btn" ${currentPage===totalPages?'disabled':''} onclick="goToPage(${totalPages})">Last</button>`;
pagination.innerHTML=html;
}
function goToPage(p){ currentPage=p; displayResults(); updatePagination(); }
async function changeItemsPerPage(){
const val=document.getElementById('itemsPerPage').value;
if(val==='all'){
if(fetchedLimit<serverTotal){
showNotification('Loading all rows…');
const {results,total}=await fetchAll(PAGE_LIMIT);
allData=results; serverTotal=total||results.length; fetchedLimit=allData.length;
populateFilters(); renderStatusLegend(); filterData(); updateStats();
}
itemsPerPage=Number.MAX_SAFE_INTEGER;
}else{
itemsPerPage=parseInt(val,10);
}
currentPage=1; displayResults(); updatePagination();
}
// =================== Stats ===================
function updateStats(){
const stats={
total: allData.length,
hosts: new Set(allData.map(i=>i.hostname||i.host)).size,
success: allData.filter(i=>i.status>=200 && i.status<300).length,
errors: allData.filter(i=>i.status>=400).length
};
document.getElementById('stats').innerHTML=`
<div class="stat-card"><div class="stat-value">${stats.total}</div><div class="stat-label">Total Results</div></div>
<div class="stat-card"><div class="stat-value">${stats.hosts}</div><div class="stat-label">Unique Hosts</div></div>
<div class="stat-card"><div class="stat-value">${stats.success}</div><div class="stat-label">Success (2xx)</div></div>
<div class="stat-card"><div class="stat-value">${stats.errors}</div><div class="stat-label">Errors (4xx/5xx)</div></div>
`;
}
// =================== Modal (IDs corrigés) ===================
function showDetails(id){
const item = allData.map(normalizeRow).find(i=>i.id===id);
if(!item) return;
const url = buildUrl(item);
const desc = getStatusText(item.status);
const modal = document.getElementById('webenumDetailModal');
const container = document.getElementById('webenumModalContent');
if(!modal || !container){
console.warn('WebEnum modal elements not found');
return;
}
container.innerHTML=`
<h2>🔍 Result Details</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:15px;margin-top:12px;">
<div><strong>Host:</strong> ${escapeHtml(item.host||'N/A')}</div>
<div><strong>IP Address:</strong> ${escapeHtml(item.ip||'N/A')}</div>
<div><strong>MAC Address:</strong> ${escapeHtml(item.mac||'N/A')}</div>
<div><strong>Port:</strong> ${item.port ?? 'N/A'}</div>
<div><strong>Directory:</strong> ${escapeHtml(item.directory||'/')}</div>
<div><strong>Status:</strong>
<span class="status ${getStatusClass(item.status)} status-${item.status}" title="${item.status}${desc}">
${item.status}
</span>
<div style="color:var(--muted);font-size:.9rem;margin-top:4px;">${desc}</div>
</div>
<div><strong>Response Size:</strong> ${formatSize(Number(item.size||0))}</div>
<div><strong>Response Time:</strong> ${item.response_time||'N/A'}ms</div>
<div><strong>Content Type:</strong> ${escapeHtml(item.content_type||'N/A')}</div>
<div><strong>Scan Date:</strong> ${formatDate(item.scan_date)}</div>
<div style="grid-column:1/-1;"><strong>Full URL:</strong> <a href="${url}" target="_blank" class="url-link">${url}</a></div>
</div>
<div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="window.open('${url}', '_blank')">🔗 Open URL</button>
<button class="btn" onclick="copyToClipboard('${url}')">📋 Copy URL</button>
<button class="btn" onclick="exportSingle(${item.id})">💾 Export</button>
</div>`;
modal.classList.add('show'); // affiche le modal (classe CSS)
}
function closeWebenumModal(){
const modal = document.getElementById('webenumDetailModal');
if(modal) modal.classList.remove('show');
}
// fermeture au clic sur le backdrop
document.addEventListener('click', (e)=>{
if(e.target && e.target.id === 'webenumDetailModal'){
closeWebenumModal();
}
});
// =================== Misc ===================
function copyToClipboard(text){ navigator.clipboard.writeText(text).then(()=>showNotification('URL copied to clipboard!')); }
function showNotification(message){
const n=document.createElement('div');
n.style.cssText=`position:fixed;top:20px;right:20px;background:var(--acid);color:var(--ink-invert);
padding:12px 20px;border-radius:12px;z-index:10000;font-weight:bold;animation:slideIn .3s ease-out;box-shadow:var(--shadow)`;
n.textContent=message; document.body.appendChild(n); setTimeout(()=>n.remove(),3000);
}
function exportResults(format){ const d=filteredData.length>0?filteredData:allData; if(format==='json')exportAsJSON(d); else if(format==='csv')exportAsCSV(d); }
function exportAsJSON(data){
const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url;
a.download=`webenum_results_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
showNotification(`Exported ${data.length} results as JSON`);
}
function exportAsCSV(data){
if(data.length===0) return;
const headers=Object.keys(data[0]);
const csv=[headers.join(','),...data.map(row=>headers.map(h=>{
const v=row[h]??''; return (typeof v==='string' && (v.includes(',')||v.includes('"'))) ? `"${v.replace(/"/g,'""')}"` : v;
}).join(','))].join('\n');
const blob=new Blob([csv],{type:'text/csv'});
const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url;
a.download=`webenum_results_${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
showNotification(`Exported ${data.length} results as CSV`);
}
function exportSingle(id){ const item=allData.find(i=>i.id===id); if(item) exportAsJSON([item]); }
async function refreshData(){ showNotification('Refreshing data...'); await loadData(); showNotification('Data refreshed successfully!'); }
function showLoading(){
document.getElementById('resultsTable').innerHTML=`
<tr><td colspan="8" class="loading"><div class="spinner"></div> Loading results...</td></tr>`;
document.getElementById('resultsCount').textContent='Loading...';
}
function showError(msg){
document.getElementById('resultsTable').innerHTML=`
<tr><td colspan="8" style="text-align:center;padding:40px;color:var(--danger);">⚠️ ${msg}</td></tr>`;
}
// =================== Keyboard nav ===================
document.addEventListener('keydown', e=>{
if(e.target.tagName.toLowerCase()==='input') return;
if(e.key==='ArrowLeft' && currentPage>1) goToPage(currentPage-1);
if(e.key==='ArrowRight'){
const totalPages=Math.ceil(filteredData.length/itemsPerPage);
if(currentPage<totalPages) goToPage(currentPage+1);
}
if(e.key==='Escape') closeWebenumModal(); // fix: appelle le bon closer
});
// Animations styles
const style=document.createElement('style');
style.textContent=`
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.highlight{background-color:color-mix(in oklab, var(--acid) 18%, transparent)!important;transition:background-color .3s ease}
.fade-out{opacity:.5;transition:opacity .3s ease}`;
document.head.appendChild(style);
// SW (optionnel)
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(console.error); }
</script>
</body>
</html>