root-me: basic ranking
This commit is contained in:
parent
5bed00bd9a
commit
7686779fbb
|
|
@ -0,0 +1,3 @@
|
||||||
|
724433
|
||||||
|
546528
|
||||||
|
|
||||||
121
public/app.js
121
public/app.js
|
|
@ -23,6 +23,11 @@ function connectWS() {
|
||||||
showAlert(msg.message, msg.html, msg.image);
|
showAlert(msg.message, msg.html, msg.image);
|
||||||
} else if (msg.type === 'dismiss') {
|
} else if (msg.type === 'dismiss') {
|
||||||
hideAlert();
|
hideAlert();
|
||||||
|
} else if (msg.type === 'rootme_update') {
|
||||||
|
renderRootme(msg.ranking);
|
||||||
|
} else if (msg.type === 'rootme_flag') {
|
||||||
|
renderRootme(rootmeCache);
|
||||||
|
showNotif(`FLAG ! ${msg.login} +${msg.gained} PTS — TOTAL : ${msg.newScore} PTS`, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -64,7 +69,7 @@ const notifBarInner = document.getElementById('notif-bar-inner');
|
||||||
const softAlarmAudio = new Audio('/soft_alarm.mp3');
|
const softAlarmAudio = new Audio('/soft_alarm.mp3');
|
||||||
let notifTimer = null;
|
let notifTimer = null;
|
||||||
|
|
||||||
function showNotif(message) {
|
function showNotif(message, sound = true) {
|
||||||
notifMessage.textContent = message;
|
notifMessage.textContent = message;
|
||||||
|
|
||||||
// Re-déclencher l'animation de la barre
|
// Re-déclencher l'animation de la barre
|
||||||
|
|
@ -78,8 +83,10 @@ function showNotif(message) {
|
||||||
|
|
||||||
notifOverlay.classList.remove('hidden');
|
notifOverlay.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (sound) {
|
||||||
softAlarmAudio.currentTime = 0;
|
softAlarmAudio.currentTime = 0;
|
||||||
softAlarmAudio.play().catch(err => console.error('soft_alarm:', err));
|
softAlarmAudio.play().catch(err => console.error('soft_alarm:', err));
|
||||||
|
}
|
||||||
|
|
||||||
if (notifTimer) clearTimeout(notifTimer);
|
if (notifTimer) clearTimeout(notifTimer);
|
||||||
notifTimer = setTimeout(() => {
|
notifTimer = setTimeout(() => {
|
||||||
|
|
@ -250,9 +257,9 @@ async function loadGeo() {
|
||||||
// Premier chargement : on mémorise sans jouer
|
// Premier chargement : on mémorise sans jouer
|
||||||
seenGeoLinks = currentLinks;
|
seenGeoLinks = currentLinks;
|
||||||
} else {
|
} else {
|
||||||
const hasNew = items.some(i => !seenGeoLinks.has(i.link));
|
const newGeoItems = items.filter(i => !seenGeoLinks.has(i.link));
|
||||||
if (hasNew) {
|
if (newGeoItems.length) {
|
||||||
showNotif('Nouvelle actualité géopolitique');
|
showNotif(`Nouvelle actualité géopolitique : ${newGeoItems[0].title}`, false);
|
||||||
seenGeoLinks = currentLinks;
|
seenGeoLinks = currentLinks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,19 +286,125 @@ async function loadGeo() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Root-me ranking ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rootmeList = document.getElementById('rootme-list');
|
||||||
|
const rootmeStatus = document.getElementById('rootme-status');
|
||||||
|
let rootmeCache = [];
|
||||||
|
|
||||||
|
function renderRootme(ranking) {
|
||||||
|
if (!ranking) return;
|
||||||
|
rootmeCache = ranking;
|
||||||
|
rootmeList.innerHTML = '';
|
||||||
|
|
||||||
|
if (!ranking.length) {
|
||||||
|
rootmeList.innerHTML = '<li class="feed-loading">Aucun joueur</li>';
|
||||||
|
rootmeStatus.textContent = '—';
|
||||||
|
rootmeStatus.className = 'widget-status';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankClass = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
|
||||||
|
|
||||||
|
ranking.forEach((user, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'rootme-item';
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="rootme-rank ${rankClass(idx)}">#${idx + 1}</span>
|
||||||
|
<span class="rootme-login">${escapeHtml(user.login)}</span>
|
||||||
|
<span class="rootme-score">${user.score.toLocaleString('fr-FR')} pts</span>
|
||||||
|
`;
|
||||||
|
rootmeList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
rootmeStatus.textContent = `${ranking.length} joueur${ranking.length > 1 ? 's' : ''}`;
|
||||||
|
rootmeStatus.className = 'widget-status ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRootme() {
|
||||||
|
rootmeStatus.textContent = '...';
|
||||||
|
rootmeStatus.className = 'widget-status';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/rootme');
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const ranking = await resp.json();
|
||||||
|
renderRootme(ranking);
|
||||||
|
} catch (err) {
|
||||||
|
rootmeStatus.textContent = 'ERREUR';
|
||||||
|
rootmeStatus.className = 'widget-status err';
|
||||||
|
rootmeList.innerHTML = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Carousel ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CAROUSEL_PAGES = [
|
||||||
|
['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-placeholder'],
|
||||||
|
['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-clock'],
|
||||||
|
];
|
||||||
|
const ALL_CAROUSEL_IDS = [...new Set(CAROUSEL_PAGES.flat())];
|
||||||
|
const CAROUSEL_FADE_MS = 450;
|
||||||
|
let carouselPage = 0;
|
||||||
|
|
||||||
|
function applyCarouselInstant() {
|
||||||
|
const visible = new Set(CAROUSEL_PAGES[carouselPage]);
|
||||||
|
ALL_CAROUSEL_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.style.opacity = '';
|
||||||
|
el.style.display = visible.has(id) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateCarousel() {
|
||||||
|
const prevVisible = new Set(CAROUSEL_PAGES[carouselPage]);
|
||||||
|
carouselPage = (carouselPage + 1) % CAROUSEL_PAGES.length;
|
||||||
|
const nextVisible = new Set(CAROUSEL_PAGES[carouselPage]);
|
||||||
|
|
||||||
|
const outgoing = ALL_CAROUSEL_IDS.filter(id => prevVisible.has(id) && !nextVisible.has(id));
|
||||||
|
const incoming = ALL_CAROUSEL_IDS.filter(id => !prevVisible.has(id) && nextVisible.has(id));
|
||||||
|
|
||||||
|
// Fade out
|
||||||
|
outgoing.forEach(id => { document.getElementById(id).style.opacity = '0'; });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// Hide outgoing, reveal incoming at opacity 0 then fade in
|
||||||
|
outgoing.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.style.opacity = '';
|
||||||
|
});
|
||||||
|
incoming.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.style.display = '';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.getBoundingClientRect(); // force reflow
|
||||||
|
el.style.opacity = '';
|
||||||
|
});
|
||||||
|
}, CAROUSEL_FADE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
infoHost.textContent = location.host;
|
infoHost.textContent = location.host;
|
||||||
|
|
||||||
|
applyCarouselInstant();
|
||||||
|
setInterval(rotateCarousel, 5 * 60 * 1000);
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
loadAnssi();
|
loadAnssi();
|
||||||
loadGeo();
|
loadGeo();
|
||||||
|
loadRootme();
|
||||||
setInterval(loadAnssi, 5 * 60 * 1000);
|
setInterval(loadAnssi, 5 * 60 * 1000);
|
||||||
setInterval(loadGeo, 5 * 60 * 1000);
|
setInterval(loadGeo, 5 * 60 * 1000);
|
||||||
|
// Root-me est mis à jour via WebSocket (rootme_update / rootme_flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Widget 5: Root-me ranking -->
|
||||||
|
<section class="widget" id="widget-placeholder">
|
||||||
|
<div class="widget-header">
|
||||||
|
<span class="widget-title">ROOT-ME RANKING</span>
|
||||||
|
<span class="widget-status" id="rootme-status">...</span>
|
||||||
|
</div>
|
||||||
|
<div class="widget-body">
|
||||||
|
<ul id="rootme-list" class="feed-list">
|
||||||
|
<li class="feed-loading">Chargement...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Widget 4: Clock + server info -->
|
<!-- Widget 4: Clock + server info -->
|
||||||
<section class="widget" id="widget-clock">
|
<section class="widget" id="widget-clock">
|
||||||
<div class="widget-header">
|
<div class="widget-header">
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ main.grid {
|
||||||
height: calc(100vh - 40px);
|
height: calc(100vh - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Carousel grid positions ─────────────────────────────────────────────── */
|
||||||
|
#widget-kaspersky { grid-column: 1; grid-row: 1; }
|
||||||
|
#widget-anssi { grid-column: 2; grid-row: 1; }
|
||||||
|
#widget-geo { grid-column: 1; grid-row: 2; }
|
||||||
|
#widget-clock { grid-column: 2; grid-row: 2; }
|
||||||
|
#widget-placeholder { grid-column: 2; grid-row: 2; }
|
||||||
|
|
||||||
/* ── Widget ──────────────────────────────────────────────────────────────── */
|
/* ── Widget ──────────────────────────────────────────────────────────────── */
|
||||||
.widget {
|
.widget {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -70,6 +77,45 @@ main.grid {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Root-me ranking ─────────────────────────────────────────────────────── */
|
||||||
|
.rootme-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootme-rank {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootme-rank.gold { color: #ffd700; text-shadow: 0 0 6px #ffd700; }
|
||||||
|
.rootme-rank.silver { color: #c0c0c0; }
|
||||||
|
.rootme-rank.bronze { color: #cd7f32; }
|
||||||
|
|
||||||
|
.rootme-login {
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootme-score {
|
||||||
|
color: var(--green-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-header {
|
.widget-header {
|
||||||
|
|
|
||||||
77
server.js
77
server.js
|
|
@ -6,6 +6,7 @@ const WebSocket = require('ws');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const { XMLParser } = require('fast-xml-parser');
|
const { XMLParser } = require('fast-xml-parser');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const PORT = process.env.DASHBOARD_PORT || 3000;
|
const PORT = process.env.DASHBOARD_PORT || 3000;
|
||||||
|
|
||||||
|
|
@ -125,18 +126,90 @@ app.get('/api/feeds/geo', async (req, res) => {
|
||||||
const parser = new XMLParser({ ignoreAttributes: false });
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
const parsed = parser.parse(xml);
|
const parsed = parser.parse(xml);
|
||||||
const items = parsed?.rss?.channel?.item || [];
|
const items = parsed?.rss?.channel?.item || [];
|
||||||
const entries = (Array.isArray(items) ? items : [items]).slice(0, 15).map(item => ({
|
const entries = (Array.isArray(items) ? items : [items])
|
||||||
|
.map(item => ({
|
||||||
title: item.title || '',
|
title: item.title || '',
|
||||||
link: item.link || '',
|
link: item.link || '',
|
||||||
pubDate: item.pubDate || '',
|
pubDate: item.pubDate || '',
|
||||||
source: item.source?.['#text'] || item.source || ''
|
source: item.source?.['#text'] || item.source || ''
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
.slice(0, 15);
|
||||||
res.json(entries);
|
res.json(entries);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(502).json({ error: 'Geo feed fetch failed', detail: err.message });
|
res.status(502).json({ error: 'Geo feed fetch failed', detail: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Root-me ranking
|
||||||
|
const ROOTME_POLL_MS = 10 * 60 * 1000;
|
||||||
|
let rootmeCache = null;
|
||||||
|
let rootmePrevScores = {}; // login → last known score
|
||||||
|
|
||||||
|
async function fetchRootmeRanking(apiKey) {
|
||||||
|
const raw = fs.readFileSync(path.resolve('logins.txt'), 'utf8');
|
||||||
|
const ids = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
const headers = { 'Cookie': `api_key=${apiKey}`, 'User-Agent': 'CyberDashboard/1.0' };
|
||||||
|
|
||||||
|
const results = await Promise.all(ids.map(async id => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://api.www.root-me.org/auteurs/${id}`,
|
||||||
|
{ headers, timeout: 10000 }
|
||||||
|
);
|
||||||
|
if (resp.status === 429) throw new Error('rate-limited');
|
||||||
|
const profile = await resp.json();
|
||||||
|
const profileRaw = Array.isArray(profile) ? profile[0] : profile;
|
||||||
|
const user = profileRaw?.['0'] ?? profileRaw;
|
||||||
|
if (!user || user.error) return null;
|
||||||
|
|
||||||
|
return { login: user.nom || id, score: Number(user.score) || 0, rank: user.position || null };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[rootme] fetch error for id "${id}":`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results.filter(Boolean).sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRootme() {
|
||||||
|
const apiKey = process.env.ROOTME_API_KEY;
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ranking = await fetchRootmeRanking(apiKey);
|
||||||
|
|
||||||
|
// Detect score gains and broadcast flag events
|
||||||
|
const isFirstPoll = Object.keys(rootmePrevScores).length === 0;
|
||||||
|
if (!isFirstPoll) {
|
||||||
|
ranking.forEach(user => {
|
||||||
|
const prev = rootmePrevScores[user.login];
|
||||||
|
if (prev !== undefined && user.score > prev) {
|
||||||
|
const gained = user.score - prev;
|
||||||
|
console.log(`[rootme] FLAG ! ${user.login} +${gained} pts (${prev} -> ${user.score})`);
|
||||||
|
broadcast({ type: 'rootme_flag', login: user.login, gained, newScore: user.score });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking.forEach(u => { rootmePrevScores[u.login] = u.score; });
|
||||||
|
rootmeCache = ranking;
|
||||||
|
broadcast({ type: 'rootme_update', ranking });
|
||||||
|
console.log(`[rootme] polled — ${ranking.length} joueur(s)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[rootme] poll error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/rootme', (req, res) => {
|
||||||
|
if (!process.env.ROOTME_API_KEY) return res.status(503).json({ error: 'ROOTME_API_KEY not configured' });
|
||||||
|
if (!rootmeCache) return res.json([]);
|
||||||
|
res.json(rootmeCache);
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Cyber Dashboard running on http://localhost:${PORT}`);
|
console.log(`Cyber Dashboard running on http://localhost:${PORT}`);
|
||||||
|
pollRootme();
|
||||||
|
setInterval(pollRootme, ROOTME_POLL_MS);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue