426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
// ── WebSocket ────────────────────────────────────────────────────────────────
|
|
|
|
let ws;
|
|
let wsReconnectTimer;
|
|
|
|
function connectWS() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${proto}//${location.host}`);
|
|
|
|
ws.addEventListener('open', () => {
|
|
setWsStatus('CONNECTÉ', 'ok');
|
|
updateInfoWS('CONNECTÉ');
|
|
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
|
});
|
|
|
|
ws.addEventListener('message', evt => {
|
|
let msg;
|
|
try { msg = JSON.parse(evt.data); } catch { return; }
|
|
|
|
if (msg.type === 'alert') {
|
|
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`);
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', () => {
|
|
setWsStatus('DÉCONNECTÉ', 'err');
|
|
updateInfoWS('DÉCONNECTÉ');
|
|
wsReconnectTimer = setTimeout(connectWS, 3000);
|
|
});
|
|
|
|
ws.addEventListener('error', () => {
|
|
ws.close();
|
|
});
|
|
}
|
|
|
|
function setWsStatus(text, cls) {
|
|
const el = document.getElementById('ws-status');
|
|
el.textContent = text;
|
|
el.className = 'widget-status ' + (cls || '');
|
|
}
|
|
|
|
function updateInfoWS(text) {
|
|
document.getElementById('info-ws').textContent = text;
|
|
}
|
|
|
|
// ── Alert overlay ────────────────────────────────────────────────────────────
|
|
|
|
const overlay = document.getElementById('alert-overlay');
|
|
const alertMessageEl = document.getElementById('alert-message');
|
|
const alertHtmlEl = document.getElementById('alert-html');
|
|
const alertIconEl = document.getElementById('alert-icon');
|
|
const alertImageEl = document.getElementById('alert-image');
|
|
const infoAlert = document.getElementById('info-alert');
|
|
|
|
// ── Notification (ANSSI / geo) ────────────────────────────────────────────────
|
|
|
|
const notifOverlay = document.getElementById('notif-overlay');
|
|
const notifMessage = document.getElementById('notif-message');
|
|
const notifBarInner = document.getElementById('notif-bar-inner');
|
|
const softAlarmAudio = new Audio('/soft_alarm.mp3');
|
|
const newsAudio = new Audio('/news.mp3');
|
|
let notifTimer = null;
|
|
|
|
function showNotif(message, audio = softAlarmAudio) {
|
|
notifMessage.textContent = message;
|
|
|
|
// Re-déclencher l'animation de la barre
|
|
notifBarInner.style.animation = 'none';
|
|
void notifBarInner.offsetWidth;
|
|
notifBarInner.style.animation = '';
|
|
|
|
notifOverlay.style.animation = 'none';
|
|
void notifOverlay.offsetWidth;
|
|
notifOverlay.style.animation = '';
|
|
|
|
notifOverlay.classList.remove('hidden');
|
|
|
|
if (audio) {
|
|
audio.currentTime = 0;
|
|
audio.play().catch(err => console.error('notif audio:', err));
|
|
}
|
|
|
|
if (notifTimer) clearTimeout(notifTimer);
|
|
notifTimer = setTimeout(() => {
|
|
notifOverlay.classList.add('hidden');
|
|
notifTimer = null;
|
|
}, 10_000);
|
|
}
|
|
|
|
// ── Alarm sound ───────────────────────────────────────────────────────────────
|
|
|
|
const alarmAudio = new Audio('/alert.mp3');
|
|
|
|
function playAlertSound() {
|
|
alarmAudio.currentTime = 0;
|
|
alarmAudio.play().catch(err => console.error('Audio play failed:', err));
|
|
}
|
|
|
|
function stopAlertSound() {
|
|
alarmAudio.pause();
|
|
alarmAudio.currentTime = 0;
|
|
}
|
|
|
|
// ── Alert overlay ─────────────────────────────────────────────────────────────
|
|
|
|
let autoDismissTimer = null;
|
|
|
|
function showAlert(message, html, image) {
|
|
// Image
|
|
if (image && image.trim()) {
|
|
alertImageEl.innerHTML = `<img src="${escapeAttr(image)}" alt="">`;
|
|
alertIconEl.style.display = 'none';
|
|
} else {
|
|
alertImageEl.innerHTML = '';
|
|
alertIconEl.style.display = '';
|
|
}
|
|
|
|
// Text / HTML
|
|
alertMessageEl.textContent = message || '';
|
|
alertMessageEl.style.display = (message && !image) ? '' : (message ? '' : 'none');
|
|
alertHtmlEl.innerHTML = (html && html.trim()) ? html : '';
|
|
|
|
// Show overlay
|
|
overlay.classList.remove('hidden');
|
|
overlay.style.animation = 'none';
|
|
void overlay.offsetWidth;
|
|
overlay.style.animation = '';
|
|
|
|
infoAlert.textContent = 'ACTIVE';
|
|
infoAlert.style.color = 'var(--red)';
|
|
|
|
playAlertSound();
|
|
|
|
if (autoDismissTimer) clearTimeout(autoDismissTimer);
|
|
autoDismissTimer = setTimeout(hideAlert, 60_000);
|
|
}
|
|
|
|
function hideAlert() {
|
|
if (autoDismissTimer) { clearTimeout(autoDismissTimer); autoDismissTimer = null; }
|
|
overlay.classList.add('hidden');
|
|
stopAlertSound();
|
|
infoAlert.textContent = 'INACTIVE';
|
|
infoAlert.style.color = '';
|
|
}
|
|
|
|
// ── Clock ────────────────────────────────────────────────────────────────────
|
|
|
|
const clockTime = document.getElementById('clock-time');
|
|
const clockDate = document.getElementById('clock-date');
|
|
const headerClock = document.getElementById('header-clock');
|
|
const infoHost = document.getElementById('info-host');
|
|
|
|
const DAYS = ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi'];
|
|
const MONTHS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
|
|
|
|
function pad(n) { return String(n).padStart(2, '0'); }
|
|
|
|
function updateClock() {
|
|
const now = new Date();
|
|
const h = pad(now.getHours());
|
|
const m = pad(now.getMinutes());
|
|
const s = pad(now.getSeconds());
|
|
const timeStr = `${h}:${m}:${s}`;
|
|
const dateStr = `${DAYS[now.getDay()]} ${pad(now.getDate())} ${MONTHS[now.getMonth()]} ${now.getFullYear()}`;
|
|
|
|
clockTime.textContent = timeStr;
|
|
clockDate.textContent = dateStr;
|
|
headerClock.textContent = `${dateStr} ${timeStr}`;
|
|
}
|
|
|
|
// ── ANSSI / CERT-FR feed ─────────────────────────────────────────────────────
|
|
|
|
const anssiList = document.getElementById('anssi-list');
|
|
const anssiStatus = document.getElementById('anssi-status');
|
|
let seenAnssiLinks = null;
|
|
|
|
async function loadAnssi() {
|
|
anssiStatus.textContent = '...';
|
|
anssiStatus.className = 'widget-status';
|
|
try {
|
|
const resp = await fetch('/api/feeds/anssi');
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const items = await resp.json();
|
|
anssiList.innerHTML = '';
|
|
|
|
if (!items.length) {
|
|
anssiList.innerHTML = '<li class="feed-loading">Aucun bulletin</li>';
|
|
return;
|
|
}
|
|
|
|
const currentAnssiLinks = new Set(items.map(i => i.link));
|
|
if (seenAnssiLinks === null) {
|
|
seenAnssiLinks = currentAnssiLinks;
|
|
} else {
|
|
const newItems = items.filter(i => !seenAnssiLinks.has(i.link));
|
|
if (newItems.length) {
|
|
showNotif(`Nouveau bulletin ANSSI : ${newItems[0].title}`);
|
|
seenAnssiLinks = currentAnssiLinks;
|
|
}
|
|
}
|
|
|
|
items.forEach(item => {
|
|
const li = document.createElement('li');
|
|
li.className = 'anssi-item';
|
|
|
|
const date = item.pubDate
|
|
? new Date(item.pubDate).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
: '';
|
|
|
|
li.innerHTML = `
|
|
<a href="${escapeAttr(item.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a>
|
|
${date ? `<span class="anssi-date">${escapeHtml(date)}</span>` : ''}
|
|
`;
|
|
anssiList.appendChild(li);
|
|
});
|
|
|
|
anssiStatus.textContent = `${items.length} bulletins`;
|
|
anssiStatus.className = 'widget-status ok';
|
|
} catch (err) {
|
|
anssiStatus.textContent = 'ERREUR';
|
|
anssiStatus.className = 'widget-status err';
|
|
anssiList.innerHTML = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
|
}
|
|
}
|
|
|
|
// ── Géopolitique feed ─────────────────────────────────────────────────────────
|
|
|
|
const geoList = document.getElementById('geo-list');
|
|
const geoStatus = document.getElementById('geo-status');
|
|
let seenGeoLinks = null;
|
|
|
|
async function loadGeo() {
|
|
geoStatus.textContent = '...';
|
|
geoStatus.className = 'widget-status';
|
|
try {
|
|
const resp = await fetch('/api/feeds/geo');
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const items = await resp.json();
|
|
geoList.innerHTML = '';
|
|
|
|
if (!items.length) {
|
|
geoList.innerHTML = '<li class="feed-loading">Aucune actualité</li>';
|
|
return;
|
|
}
|
|
|
|
const currentLinks = new Set(items.map(i => i.link));
|
|
|
|
if (seenGeoLinks === null) {
|
|
// Premier chargement : on mémorise sans jouer
|
|
seenGeoLinks = currentLinks;
|
|
} else {
|
|
const newGeoItems = items.filter(i => !seenGeoLinks.has(i.link));
|
|
if (newGeoItems.length) {
|
|
showNotif(`Nouvelle actualité géopolitique : ${newGeoItems[0].title}`, newsAudio);
|
|
seenGeoLinks = currentLinks;
|
|
}
|
|
}
|
|
|
|
items.forEach(item => {
|
|
const li = document.createElement('li');
|
|
li.className = 'anssi-item';
|
|
const date = item.pubDate
|
|
? new Date(item.pubDate).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
: '';
|
|
li.innerHTML = `
|
|
<a href="${escapeAttr(item.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a>
|
|
${date ? `<span class="anssi-date">${escapeHtml(item.source ? item.source + ' · ' : '')}${escapeHtml(date)}</span>` : ''}
|
|
`;
|
|
geoList.appendChild(li);
|
|
});
|
|
|
|
geoStatus.textContent = `${items.length} articles`;
|
|
geoStatus.className = 'widget-status ok';
|
|
} catch (err) {
|
|
geoStatus.textContent = 'ERREUR';
|
|
geoStatus.className = 'widget-status err';
|
|
geoList.innerHTML = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
|
|
}
|
|
}
|
|
|
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
return String(str).replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|