root-me: basic ranking

This commit is contained in:
Lopinosaurus 2026-03-11 19:35:15 +01:00
parent 5bed00bd9a
commit 7686779fbb
5 changed files with 260 additions and 12 deletions

3
logins.txt Normal file
View File

@ -0,0 +1,3 @@
724433
546528

View File

@ -23,6 +23,11 @@ function connectWS() {
showAlert(msg.message, msg.html, msg.image);
} else if (msg.type === 'dismiss') {
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');
let notifTimer = null;
function showNotif(message) {
function showNotif(message, sound = true) {
notifMessage.textContent = message;
// Re-déclencher l'animation de la barre
@ -78,8 +83,10 @@ function showNotif(message) {
notifOverlay.classList.remove('hidden');
softAlarmAudio.currentTime = 0;
softAlarmAudio.play().catch(err => console.error('soft_alarm:', err));
if (sound) {
softAlarmAudio.currentTime = 0;
softAlarmAudio.play().catch(err => console.error('soft_alarm:', err));
}
if (notifTimer) clearTimeout(notifTimer);
notifTimer = setTimeout(() => {
@ -250,9 +257,9 @@ async function loadGeo() {
// Premier chargement : on mémorise sans jouer
seenGeoLinks = currentLinks;
} else {
const hasNew = items.some(i => !seenGeoLinks.has(i.link));
if (hasNew) {
showNotif('Nouvelle actualité géopolitique');
const newGeoItems = items.filter(i => !seenGeoLinks.has(i.link));
if (newGeoItems.length) {
showNotif(`Nouvelle actualité géopolitique : ${newGeoItems[0].title}`, false);
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 ─────────────────────────────────────────────────────────────────────
function init() {
infoHost.textContent = location.host;
applyCarouselInstant();
setInterval(rotateCarousel, 5 * 60 * 1000);
connectWS();
updateClock();
setInterval(updateClock, 1000);
loadAnssi();
loadGeo();
loadRootme();
setInterval(loadAnssi, 5 * 60 * 1000);
setInterval(loadGeo, 5 * 60 * 1000);
// Root-me est mis à jour via WebSocket (rootme_update / rootme_flag)
}
// ── Helpers ───────────────────────────────────────────────────────────────────

View File

@ -81,6 +81,19 @@
</div>
</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 -->
<section class="widget" id="widget-clock">
<div class="widget-header">

View File

@ -62,6 +62,13 @@ main.grid {
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 {
display: flex;
@ -70,6 +77,45 @@ main.grid {
border: 1px solid var(--border);
border-radius: 4px;
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 {

View File

@ -6,6 +6,7 @@ const WebSocket = require('ws');
const fetch = require('node-fetch');
const { XMLParser } = require('fast-xml-parser');
const path = require('path');
const fs = require('fs');
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 parsed = parser.parse(xml);
const items = parsed?.rss?.channel?.item || [];
const entries = (Array.isArray(items) ? items : [items]).slice(0, 15).map(item => ({
title: item.title || '',
link: item.link || '',
pubDate: item.pubDate || '',
source: item.source?.['#text'] || item.source || ''
}));
const entries = (Array.isArray(items) ? items : [items])
.map(item => ({
title: item.title || '',
link: item.link || '',
pubDate: item.pubDate || '',
source: item.source?.['#text'] || item.source || ''
}))
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
.slice(0, 15);
res.json(entries);
} catch (err) {
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, () => {
console.log(`Cyber Dashboard running on http://localhost:${PORT}`);
pollRootme();
setInterval(pollRootme, ROOTME_POLL_MS);
});