- site vitrine + fingerprint test + zmap + z-panel - remplacement duckdns -> riricdev.tail5ea5cd.ts.net (canonical/og/alternates/docs) - suppression du token DuckDNS (plus utilisé)
1035 lines
35 KiB
JavaScript
1035 lines
35 KiB
JavaScript
"use strict";
|
||
|
||
const API = {
|
||
state: "/z-panel/state",
|
||
setup: "/z-panel/setup",
|
||
login: "/z-panel/login",
|
||
me: "/z-panel/me",
|
||
stats: "/z-panel/api/stats",
|
||
timeline: "/z-panel/api/timeline",
|
||
logs: "/z-panel/api/logs",
|
||
system: "/z-panel/api/system",
|
||
restart: "/z-panel/api/restart",
|
||
articles: "/z-panel/api/articles",
|
||
i18n: "/z-panel/api/i18n",
|
||
rules: "/z-panel/api/rules",
|
||
honeypot: "/z-panel/api/honeypot",
|
||
zmap: "/z-panel/api/zmap",
|
||
};
|
||
|
||
const TOKEN_KEY = "zektyc_admin_token";
|
||
const REFRESH_MS = 15000;
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
let authMode = "login";
|
||
let token = sessionStorage.getItem(TOKEN_KEY) || "";
|
||
let autoTimer = null;
|
||
let logTimer = null;
|
||
|
||
function authHeaders() {
|
||
return token ? { Authorization: "Bearer " + token } : {};
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const headers = Object.assign({}, opts.headers || {}, authHeaders());
|
||
if (opts.body) headers["Content-Type"] = "application/json";
|
||
const res = await fetch(path, Object.assign({}, opts, { headers }));
|
||
let data = null;
|
||
try {
|
||
data = await res.json();
|
||
} catch {}
|
||
if (res.status === 401 && !opts._noAuth) {
|
||
clearSession();
|
||
throw new Error("Session expirée");
|
||
}
|
||
return { status: res.status, data };
|
||
}
|
||
|
||
function clearSession() {
|
||
token = "";
|
||
sessionStorage.removeItem(TOKEN_KEY);
|
||
$("app").hidden = true;
|
||
$("login").hidden = false;
|
||
setAuthMode("login");
|
||
}
|
||
|
||
function fmtDate(ts) {
|
||
const d = new Date(ts * 1000);
|
||
const p = (n) => String(n).padStart(2, "0");
|
||
return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (n == null) return "-";
|
||
if (n < 1024) return n + " B";
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " Ko";
|
||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " Mo";
|
||
return (n / 1024 / 1024 / 1024).toFixed(2) + " Go";
|
||
}
|
||
|
||
function fmtUptime(s) {
|
||
if (s == null) return "-";
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
if (d > 0) return `${d} j ${h} h ${m} min`;
|
||
if (h > 0) return `${h} h ${m} min`;
|
||
return `${m} min`;
|
||
}
|
||
|
||
function pillClass(status) {
|
||
const s = Number(status);
|
||
if (s >= 500) return "s5xx";
|
||
if (s >= 400) return "s4xx";
|
||
if (s >= 300) return "s3xx";
|
||
if (s >= 200) return "s2xx";
|
||
return "s0";
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
||
);
|
||
}
|
||
|
||
/* ---------- Authentification ---------- */
|
||
|
||
function setAuthMode(mode) {
|
||
authMode = mode;
|
||
const btn = $("auth-btn");
|
||
if (mode === "setup") {
|
||
$("auth-form").querySelector("label").textContent = "Définir un mot de passe";
|
||
btn.textContent = "Configurer et se connecter";
|
||
$("password").autocomplete = "new-password";
|
||
} else {
|
||
$("auth-form").querySelector("label").textContent = "Mot de passe";
|
||
btn.textContent = "Connexion";
|
||
$("password").autocomplete = "current-password";
|
||
}
|
||
}
|
||
|
||
function setMsg(text, cls) {
|
||
const m = $("auth-msg");
|
||
m.textContent = text;
|
||
m.className = "msg" + (cls ? " " + cls : "");
|
||
}
|
||
|
||
async function initAuth() {
|
||
$("app").hidden = true;
|
||
$("login").hidden = false;
|
||
if (token) {
|
||
const r = await api(API.me, { _noAuth: true });
|
||
if (r.status === 200) {
|
||
$("login").hidden = true;
|
||
$("app").hidden = false;
|
||
startAutoRefresh();
|
||
return;
|
||
}
|
||
token = "";
|
||
sessionStorage.removeItem(TOKEN_KEY);
|
||
}
|
||
const st = await api(API.state, { _noAuth: true });
|
||
if (st.status !== 200) {
|
||
setMsg("Impossible de contacter le serveur.", "err");
|
||
return;
|
||
}
|
||
setAuthMode(st.data && st.data.configured ? "login" : "setup");
|
||
if (st.data && st.data.configured === false) {
|
||
setMsg("Premier lancement : définissez votre mot de passe.", "");
|
||
}
|
||
}
|
||
|
||
async function submitAuth(e) {
|
||
e.preventDefault();
|
||
const password = $("password").value;
|
||
setMsg("", "");
|
||
$("auth-btn").disabled = true;
|
||
try {
|
||
if (authMode === "setup") {
|
||
const s = await api(API.setup, { method: "POST", body: JSON.stringify({ password }) });
|
||
if (s.status !== 200) {
|
||
setMsg(s.data && s.data.error ? s.data.error : "Erreur", "err");
|
||
return;
|
||
}
|
||
}
|
||
const r = await api(API.login, { method: "POST", body: JSON.stringify({ password }) });
|
||
if (r.status === 429) {
|
||
setMsg("Trop de tentatives. Réessayez dans quelques minutes.", "err");
|
||
return;
|
||
}
|
||
if (r.status === 401) {
|
||
setMsg("Mot de passe incorrect.", "err");
|
||
return;
|
||
}
|
||
if (r.status === 409) {
|
||
setAuthMode("login");
|
||
setMsg("Le mot de passe a déjà été configuré. Connectez-vous.", "err");
|
||
return;
|
||
}
|
||
if (r.status !== 200) {
|
||
setMsg(r.data && r.data.error ? r.data.error : "Erreur", "err");
|
||
return;
|
||
}
|
||
token = r.data.token;
|
||
sessionStorage.setItem(TOKEN_KEY, token);
|
||
$("password").value = "";
|
||
setMsg("", "");
|
||
$("login").hidden = true;
|
||
$("app").hidden = false;
|
||
startAutoRefresh();
|
||
} catch (err) {
|
||
setMsg(err.message || "Erreur réseau", "err");
|
||
} finally {
|
||
$("auth-btn").disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------- Navigation par onglets ---------- */
|
||
|
||
const TITLES = {
|
||
dash: "Tableau de bord",
|
||
logs: "Logs",
|
||
system: "Système",
|
||
articles: "Articles",
|
||
trans: "Traductions",
|
||
rules: "Règles Discord",
|
||
honeypot: "Honeypot",
|
||
zmap: "ZMap",
|
||
restart: "Redémarrage",
|
||
};
|
||
|
||
function switchTab(name) {
|
||
for (const b of document.querySelectorAll(".nav-item")) {
|
||
b.classList.toggle("active", b.dataset.tab === name);
|
||
}
|
||
for (const sec of document.querySelectorAll(".tab")) {
|
||
sec.hidden = sec.id !== "tab-" + name;
|
||
}
|
||
$("page-title").textContent = TITLES[name];
|
||
if (name === "logs") loadLogs();
|
||
if (name === "system") loadSystem();
|
||
if (name === "zmap") loadZmap();
|
||
if (name === "articles") loadArticles();
|
||
if (name === "trans") loadTranslations();
|
||
if (name === "rules") loadRules();
|
||
if (name === "honeypot") loadHoneypot();
|
||
}
|
||
|
||
/* ---------- Tableau de bord ---------- */
|
||
|
||
async function loadDashboard() {
|
||
const [statsRes, tlRes] = await Promise.all([api(API.stats), api(API.timeline)]);
|
||
if (statsRes.status !== 200 || tlRes.status !== 200) throw new Error("stats");
|
||
renderStats(statsRes.data);
|
||
renderTimeline(tlRes.data);
|
||
}
|
||
|
||
function renderStats(s) {
|
||
const total = s.total || 0;
|
||
const by = s.byStatus || {};
|
||
const cards = [
|
||
[total, "requêtes", "stat-value"],
|
||
[by["200"] ?? 0, "200 OK", "stat-value"],
|
||
[by["404"] ?? 0, "404 bloqués", "stat-value"],
|
||
[by["429"] ?? 0, "429 rate-limit", "stat-value"],
|
||
[by["405"] ?? 0, "405 refusés", "stat-value"],
|
||
[Object.keys(by).length, "statuts distincts", "stat-value"],
|
||
];
|
||
document.querySelectorAll("#stat-cards .stat-card").forEach((card, i) => {
|
||
const [v, label] = cards[i];
|
||
card.querySelector(".stat-value").textContent = v.toLocaleString("fr-FR");
|
||
card.querySelector(".stat-label").textContent = label;
|
||
});
|
||
const blocked = s.blocked || [];
|
||
$("blocked-count").textContent = blocked.length + " cibles";
|
||
if (blocked.length === 0) {
|
||
$("blocked-table").innerHTML = '<p class="meta">Aucun 404.</p>';
|
||
} else {
|
||
$("blocked-table").innerHTML =
|
||
"<table><thead><tr><th>Chemin</th><th>Hits</th></tr></thead><tbody>" +
|
||
blocked
|
||
.map(
|
||
([uri, n]) =>
|
||
`<tr><td class="uri">${esc(uri)}</td><td class="hit"><b>${n.toLocaleString("fr-FR")}</b></td></tr>`
|
||
)
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
const by429 = Object.entries(s.by429 || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||
$("ratelimit-count").textContent = by429.length + " IP";
|
||
if (by429.length === 0) {
|
||
$("ratelimit-table").innerHTML = '<p class="meta">Aucun 429.</p>';
|
||
} else {
|
||
$("ratelimit-table").innerHTML =
|
||
"<table><thead><tr><th>IP</th><th>Limitations</th></tr></thead><tbody>" +
|
||
by429
|
||
.map(
|
||
([ip, n]) =>
|
||
`<tr><td class="ip">${esc(ip)}</td><td class="hit"><b>${n.toLocaleString("fr-FR")}</b></td></tr>`
|
||
)
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
}
|
||
|
||
function renderTimeline(data) {
|
||
const W = 900;
|
||
const H = 160;
|
||
const padB = 26;
|
||
const padT = 8;
|
||
const n = data.length;
|
||
const max = Math.max.apply(null, data.map((d) => d.total).concat([1]));
|
||
const band = W / n;
|
||
const bw = Math.max(band * 0.6, 1);
|
||
const plotH = H - padB - padT;
|
||
const seg = (v) => Math.round((v / max) * plotH);
|
||
|
||
let rects = "";
|
||
let labels = "";
|
||
data.forEach((d, i) => {
|
||
const x = i * band + (band - bw) / 2;
|
||
let y = padT + plotH;
|
||
const stack = [];
|
||
if (d.ok) stack.push(["#34d399", d.ok]);
|
||
if (d.redir) stack.push(["#58a6ff", d.redir]);
|
||
if (d.err4) stack.push(["#fbbf24", d.err4]);
|
||
if (d.err5) stack.push(["#f87171", d.err5]);
|
||
if (d.total === 0) {
|
||
rects += `<rect x="${x.toFixed(1)}" y="${(padT + plotH - 1).toFixed(1)}" width="${bw.toFixed(1)}" height="1" fill="#1b2430"/>`;
|
||
}
|
||
const tooltip =
|
||
`<title>${fmtDate(d.hour)} : ${d.total} req` +
|
||
(d.ok ? ` · ${d.ok} OK` : "") +
|
||
(d.err4 ? ` · ${d.err4} 4xx` : "") +
|
||
(d.err5 ? ` · ${d.err5} 5xx` : "") +
|
||
`</title>`;
|
||
for (const [color, v] of stack) {
|
||
const h = seg(v);
|
||
y -= h;
|
||
rects += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${h}" fill="${color}" rx="1">${tooltip}</rect>`;
|
||
}
|
||
if (i % 6 === 0) {
|
||
const h = new Date(d.hour * 1000).getHours();
|
||
labels += `<text x="${(x + band / 2).toFixed(1)}" y="${H - 8}" text-anchor="middle" font-size="10" fill="#8b96a6">${h}h</text>`;
|
||
}
|
||
});
|
||
|
||
const first = data[0];
|
||
const last = data[n - 1];
|
||
$("chart-meta").textContent = `${fmtDate(first.hour)} → ${fmtDate(last.hour)}`;
|
||
$("chart").innerHTML = n
|
||
? `<svg class="chart-svg" viewBox="0 0 ${W} ${H}" role="img" aria-label="Requêtes par heure">${rects}${labels}</svg>`
|
||
: '<p class="meta">Aucune donnée.</p>';
|
||
}
|
||
|
||
/* ---------- Logs ---------- */
|
||
|
||
let logFilterTimer = null;
|
||
const LOG_EXCLUDE_KEY = "zektyc_log_exclude";
|
||
let logExclude = localStorage.getItem(LOG_EXCLUDE_KEY) !== "0";
|
||
|
||
function renderLogExclude() {
|
||
const btn = $("log-exclude");
|
||
btn.setAttribute("aria-pressed", String(logExclude));
|
||
btn.classList.toggle("active", logExclude);
|
||
$("log-exclude-label").textContent = logExclude ? "Masquer le bruit" : "Tout afficher";
|
||
}
|
||
|
||
function setLogExclude(v) {
|
||
logExclude = v;
|
||
localStorage.setItem(LOG_EXCLUDE_KEY, v ? "1" : "0");
|
||
renderLogExclude();
|
||
loadLogs();
|
||
}
|
||
|
||
async function loadLogs() {
|
||
const filter = $("log-filter").value.trim();
|
||
const status = $("log-status").value;
|
||
const q = new URLSearchParams({ lines: "300" });
|
||
if (filter) q.set("filter", filter);
|
||
if (status) q.set("status", status);
|
||
if (logExclude) q.set("exclude", "1");
|
||
const r = await api(API.logs + "?" + q.toString());
|
||
if (r.status !== 200) return;
|
||
const { entries, total, truncated, returned } = r.data;
|
||
$("log-meta").textContent =
|
||
`${returned} affichées · ${total.toLocaleString("fr-FR")} au total` + (truncated ? " · tronqué" : "");
|
||
const tbody = $("log-table").querySelector("tbody");
|
||
if (entries.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7" class="meta">Aucune entrée.</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = entries
|
||
.slice()
|
||
.reverse()
|
||
.map((e) => {
|
||
const d =
|
||
typeof e.duration === "number"
|
||
? (e.duration < 100 ? e.duration.toFixed(1) : Math.round(e.duration)) + " ms"
|
||
: "-";
|
||
const ua = (e.ua || "-").length > 60 ? (e.ua || "-").slice(0, 60) + "…" : e.ua || "-";
|
||
return `<tr>
|
||
<td class="mono">${fmtDate(e.ts)}</td>
|
||
<td class="ip">${esc(e.ip)}</td>
|
||
<td>${esc(e.method)}</td>
|
||
<td class="uri" title="${esc(e.uri)}">${esc(e.uri)}</td>
|
||
<td><span class="pill ${pillClass(e.status)}">${esc(e.status)}</span></td>
|
||
<td class="hit">${d}</td>
|
||
<td class="muted" title="${esc(e.ua)}">${esc(ua)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
/* ---------- Système ---------- */
|
||
|
||
function fmtLoadAvg(la, cpus) {
|
||
if (!la) return "-";
|
||
const parts = la.split(" ");
|
||
const cores = typeof cpus === "number" ? cpus : parts.length > 3 ? parts[3].split("/")[1] : null;
|
||
return `${parts[0]} / ${parts[1]} / ${parts[2]}` + (cores ? ` (${cores} cœur${cores > 1 ? "s" : ""})` : "");
|
||
}
|
||
|
||
async function loadSystem() {
|
||
const r = await api(API.system);
|
||
if (r.status !== 200) return;
|
||
const s = r.data;
|
||
const mem = s.mem || {};
|
||
const total = mem.MemTotal || 0;
|
||
const available = mem.MemAvailable ?? total;
|
||
const used = Math.max(total - available, 0);
|
||
const pct = total ? Math.round((used / total) * 100) : 0;
|
||
|
||
const cards = [
|
||
[fmtUptime(s.uptime), "uptime"],
|
||
[fmtLoadAvg(s.loadavg, s.cpus), "charge CPU (1/5/15)"],
|
||
[`${pct} % · ${fmtBytes(used)}`, "mémoire utilisée"],
|
||
[String(s.procs ? s.procs.length : 0), "processus suivis"],
|
||
];
|
||
$("sys-grid").innerHTML = cards
|
||
.map(
|
||
([v, label]) => `<div class="stat-card">
|
||
<div class="stat-body"><b class="stat-value">${esc(v)}</b><span class="stat-label">${esc(label)}</span></div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
|
||
if (!s.procs || s.procs.length === 0) {
|
||
$("procs").innerHTML = '<p class="meta">Aucun processus.</p>';
|
||
} else {
|
||
$("procs").innerHTML =
|
||
"<table><thead><tr><th>Nom</th><th>PID</th><th>RAM</th><th>Commande</th></tr></thead><tbody>" +
|
||
s.procs
|
||
.sort((a, b) => (b.rss || 0) - (a.rss || 0))
|
||
.map(
|
||
(p) =>
|
||
`<tr><td>${esc(p.name)}</td><td class="ip">${p.pid}</td><td class="hit">${fmtBytes(p.rss)}</td><td class="uri" title="${esc(p.cmd)}">${esc(p.cmd)}</td></tr>`
|
||
)
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
|
||
if (!s.disk) {
|
||
$("disk").innerHTML = '<p class="meta">Indisponible.</p>';
|
||
} else {
|
||
const rows = s.disk.slice(1).map((line) => line.trim().split(/\s+/));
|
||
$("disk").innerHTML =
|
||
"<table><thead><tr><th>Fichier</th><th>Taille</th><th>Utilisé</th><th>Disponible</th><th>%</th><th>Monté sur</th></tr></thead><tbody>" +
|
||
rows
|
||
.map(
|
||
(c) =>
|
||
`<tr><td class="ip">${esc(c[0])}</td><td class="hit">${fmtBytes(Number(c[1]) * 1024)}</td><td class="hit">${fmtBytes(Number(c[2]) * 1024)}</td><td class="hit">${fmtBytes(Number(c[3]) * 1024)}</td><td class="hit">${esc(c[4])}</td><td>${esc(c[5])}</td></tr>`
|
||
)
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
}
|
||
|
||
/* ---------- ZMap ---------- */
|
||
|
||
async function loadZmap() {
|
||
const r = await api(API.zmap);
|
||
if (r.status !== 200) return;
|
||
const d = r.data;
|
||
const b = d.build || {};
|
||
const grids = d.grids || {};
|
||
|
||
// stat cards
|
||
const bRunning = b.running;
|
||
const bPass = b.done ? "terminé" : b.pass ? `pass ${b.pass}` : "—";
|
||
const bWays = b.ways ? `${(b.ways / 1e6).toFixed(1)}M` : "—";
|
||
const bNodes = b.nodes ? `${(b.nodes / 1e6).toFixed(1)}M` : "—";
|
||
const bRss = b.rss ? fmtBytes(b.rss) : "—";
|
||
const cards = [
|
||
[bRunning ? "En cours" : (b.done ? "Terminé" : "Arrêté"), "état build"],
|
||
[bPass, "pass actuel"],
|
||
[bWays, "ways traités"],
|
||
[bNodes, "nœuds collectés"],
|
||
[bRss, "RAM build"],
|
||
[grids.europe ? fmtBytes(grids.europe.totalSize) : "—", "grille Europe"],
|
||
[grids.planet ? fmtBytes(grids.planet.totalSize) : "—", "grille Monde"],
|
||
];
|
||
$("zmap-grid").innerHTML = cards.map(([v, label]) =>
|
||
`<div class="stat-card"><div class="stat-body"><b class="stat-value">${esc(v)}</b><span class="stat-label">${esc(label)}</span></div></div>`
|
||
).join("");
|
||
|
||
// build info
|
||
$("zmap-build-meta").textContent = b.pid ? `PID ${b.pid}` : "";
|
||
if (b.done) {
|
||
$("zmap-build-info").innerHTML = '<p class="meta">Build terminé.</p>';
|
||
} else if (!b.running) {
|
||
$("zmap-build-info").innerHTML = '<p class="meta">Aucun build en cours.</p>';
|
||
} else {
|
||
const pctWays = b.ways ? Math.min(100, Math.round((b.ways / 50e6) * 100)) : 0;
|
||
const pctNodes = b.nodes ? Math.min(100, Math.round((b.nodes / 500e6) * 100)) : 0;
|
||
const bar = (pct, label) => `<div class="bar-row"><span class="bar-label">${label}</span><div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div><span class="bar-pct">${pct}%</span></div>`;
|
||
$("zmap-build-info").innerHTML =
|
||
`<div class="build-bars">${bar(pctWays, "Ways (~50M)")}${bar(pctNodes, "Nœuds (~500M)")}</div>`;
|
||
}
|
||
|
||
// grids
|
||
$("zmap-grids-meta").textContent = "";
|
||
const gNames = { europe: "Europe", planet: "Monde" };
|
||
let gHtml = '<table><thead><tr><th>Grille</th><th>Points</th><th>Cellules</th><th>Taille gzip</th><th>Couverture</th></tr></thead><tbody>';
|
||
for (const [key, label] of Object.entries(gNames)) {
|
||
const g = grids[key];
|
||
if (g) {
|
||
const cover = (g.lonMin != null) ? `${g.lonMin}°…${g.lonMax}° lon, ${g.latMin}°…${g.latMax}° lat` : "—";
|
||
gHtml += `<tr><td>${esc(label)}</td><td class="hit">${g.points?.toLocaleString("fr-FR") ?? "—"}</td><td class="hit">${g.cells?.toLocaleString("fr-FR") ?? "—"}</td><td class="hit">${fmtBytes(g.totalSize)}</td><td class="uri">${esc(cover)}</td></tr>`;
|
||
} else {
|
||
gHtml += `<tr><td>${esc(label)}</td><td colspan="4" class="meta">pas encore construite</td></tr>`;
|
||
}
|
||
}
|
||
gHtml += '</tbody></table>';
|
||
$("zmap-grids-info").innerHTML = gHtml;
|
||
|
||
// log
|
||
const logLines = b.log || [];
|
||
$("zmap-build-log").textContent = logLines.join("\n") || "Aucun log.";
|
||
}
|
||
|
||
/* ---------- Articles ---------- */
|
||
|
||
let articleSlug = null;
|
||
|
||
function setArticleMsg(text, cls) {
|
||
const m = $("art-msg");
|
||
m.textContent = text;
|
||
m.className = "msg" + (cls ? " " + cls : "");
|
||
}
|
||
|
||
async function loadArticles() {
|
||
const r = await api(API.articles);
|
||
if (r.status !== 200) return;
|
||
const list = r.data || [];
|
||
$("articles-meta").textContent = list.length + " article" + (list.length > 1 ? "s" : "");
|
||
if (list.length === 0) {
|
||
$("articles-list").innerHTML = '<p class="meta">Aucun article. Créez le premier.</p>';
|
||
return;
|
||
}
|
||
$("articles-list").innerHTML =
|
||
"<table><thead><tr><th>Slug</th><th>Titre FR</th><th>Titre EN</th><th>Publié</th><th>Modifié</th><th class='col-actions'>Actions</th></tr></thead><tbody>" +
|
||
list
|
||
.map(
|
||
(a) =>
|
||
`<tr>
|
||
<td class="mono">${esc(a.slug)}</td>
|
||
<td class="uri" title="${esc(a.frTitle)}">${esc(a.frTitle)}</td>
|
||
<td class="uri" title="${esc(a.enTitle)}">${esc(a.enTitle)}</td>
|
||
<td class="hit">${esc(a.published)}</td>
|
||
<td class="hit muted">${esc(a.modified)}</td>
|
||
<td class="col-actions">
|
||
<div class="row-actions">
|
||
<button type="button" class="btn-ghost btn-sm" data-action="edit" data-slug="${esc(a.slug)}">Modifier</button>
|
||
<button type="button" class="btn-ghost btn-sm" data-action="view" data-slug="${esc(a.slug)}">Voir</button>
|
||
<button type="button" class="btn-danger btn-sm" data-action="delete" data-slug="${esc(a.slug)}">Supprimer</button>
|
||
</div>
|
||
</td>
|
||
</tr>`
|
||
)
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
|
||
function fillArticleForm(article) {
|
||
$("art-slug").value = article.slug;
|
||
$("art-published").value = article.published || "";
|
||
$("art-fr-title").value = (article.fr && article.fr.title) || "";
|
||
$("art-fr-og").value = (article.fr && article.fr.ogTitle) || "";
|
||
$("art-fr-desc").value = (article.fr && article.fr.description) || "";
|
||
$("art-fr-body").value = (article.fr && article.fr.body) || "";
|
||
$("art-en-title").value = (article.en && article.en.title) || "";
|
||
$("art-en-og").value = (article.en && article.en.ogTitle) || "";
|
||
$("art-en-desc").value = (article.en && article.en.description) || "";
|
||
$("art-en-body").value = (article.en && article.en.body) || "";
|
||
articleSlug = article.slug;
|
||
$("article-editor-title").textContent = "Modifier l'article";
|
||
$("article-editor-meta").textContent = "/actualites/" + article.slug + "/";
|
||
$("art-delete").hidden = false;
|
||
$("article-editor").hidden = false;
|
||
setArticleMsg("", "");
|
||
$("article-editor").scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
|
||
async function openArticleEditor(slug) {
|
||
if (slug) {
|
||
const r = await api(API.articles + "/" + slug);
|
||
if (r.status !== 200) return;
|
||
fillArticleForm(r.data);
|
||
return;
|
||
}
|
||
for (const id of [
|
||
"art-slug",
|
||
"art-fr-title",
|
||
"art-fr-og",
|
||
"art-fr-desc",
|
||
"art-fr-body",
|
||
"art-en-title",
|
||
"art-en-og",
|
||
"art-en-desc",
|
||
"art-en-body",
|
||
]) {
|
||
$(id).value = "";
|
||
}
|
||
$("art-published").value = new Date().toISOString().slice(0, 10);
|
||
articleSlug = null;
|
||
$("article-editor-title").textContent = "Nouvel article";
|
||
$("article-editor-meta").textContent = "";
|
||
$("art-delete").hidden = true;
|
||
$("article-editor").hidden = false;
|
||
setArticleMsg("", "");
|
||
$("article-editor").scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
|
||
function collectArticleForm() {
|
||
return {
|
||
slug: $("art-slug").value.trim(),
|
||
published: $("art-published").value,
|
||
fr: {
|
||
title: $("art-fr-title").value,
|
||
ogTitle: $("art-fr-og").value,
|
||
description: $("art-fr-desc").value,
|
||
body: $("art-fr-body").value,
|
||
},
|
||
en: {
|
||
title: $("art-en-title").value,
|
||
ogTitle: $("art-en-og").value,
|
||
description: $("art-en-desc").value,
|
||
body: $("art-en-body").value,
|
||
},
|
||
};
|
||
}
|
||
|
||
function copyLangFields(from, to) {
|
||
$("art-" + to + "-title").value = $("art-" + from + "-title").value;
|
||
$("art-" + to + "-og").value = $("art-" + from + "-og").value;
|
||
$("art-" + to + "-desc").value = $("art-" + from + "-desc").value;
|
||
$("art-" + to + "-body").value = $("art-" + from + "-body").value;
|
||
setArticleMsg(
|
||
from === "fr"
|
||
? "Contenu français copié en anglais — traduis puis publie."
|
||
: "Contenu anglais copié en français — traduis puis publie.",
|
||
"ok"
|
||
);
|
||
}
|
||
|
||
function togglePreview(lang) {
|
||
const pv = $("art-" + lang + "-preview");
|
||
if (pv.hidden) {
|
||
const ta = $("art-" + lang + "-body");
|
||
pv.innerHTML = window.marked ? window.marked.parse(ta.value || "") : "";
|
||
pv.hidden = false;
|
||
} else {
|
||
pv.hidden = true;
|
||
}
|
||
}
|
||
|
||
async function saveArticle(e) {
|
||
e.preventDefault();
|
||
const body = collectArticleForm();
|
||
if (!/^[a-z0-9-]+$/.test(body.slug)) {
|
||
setArticleMsg("Slug invalide : minuscules a-z, chiffres et tirets uniquement.", "err");
|
||
return;
|
||
}
|
||
if (!body.published) {
|
||
setArticleMsg("Date de publication requise.", "err");
|
||
return;
|
||
}
|
||
if (
|
||
!body.fr.title.trim() ||
|
||
!body.en.title.trim() ||
|
||
!body.fr.body.trim() ||
|
||
!body.en.body.trim()
|
||
) {
|
||
setArticleMsg("Les titres et contenus FR et EN sont requis.", "err");
|
||
return;
|
||
}
|
||
setArticleMsg("", "");
|
||
$("art-save").disabled = true;
|
||
try {
|
||
const method = articleSlug ? "PUT" : "POST";
|
||
const r = await api(API.articles, { method, body: JSON.stringify(body) });
|
||
if (r.status !== 200 && r.status !== 201) {
|
||
setArticleMsg(r.data && r.data.error ? r.data.error : "Erreur", "err");
|
||
return;
|
||
}
|
||
setArticleMsg(articleSlug ? "Article mis à jour." : "Article créé.", "ok");
|
||
$("article-editor").hidden = true;
|
||
loadArticles();
|
||
} catch (err) {
|
||
if (err && err.message === "Session expirée") return;
|
||
setArticleMsg("Erreur réseau", "err");
|
||
} finally {
|
||
$("art-save").disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteArticle(slug) {
|
||
if (!window.confirm(`Supprimer l'article « ${slug} » ? Cette action est définitive.`)) return;
|
||
const r = await api(API.articles + "/" + slug, { method: "DELETE" });
|
||
if (r.status !== 200) {
|
||
if (r.status === 401) return;
|
||
window.alert(r.data && r.data.error ? r.data.error : "Erreur");
|
||
return;
|
||
}
|
||
if (articleSlug === slug) $("article-editor").hidden = true;
|
||
loadArticles();
|
||
}
|
||
|
||
/* ---------- Traductions ---------- */
|
||
|
||
let transKeys = [];
|
||
|
||
function setTransMsg(text, cls) {
|
||
const m = $("trans-msg");
|
||
m.textContent = text;
|
||
m.className = "msg" + (cls ? " " + cls : "");
|
||
}
|
||
|
||
function autoGrow(ta) {
|
||
ta.style.height = "auto";
|
||
ta.style.height = Math.max(ta.scrollHeight + 4, 40) + "px";
|
||
}
|
||
|
||
function transRow(key, fr, en) {
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.key = key;
|
||
tr.innerHTML =
|
||
`<td class="mono trans-key" title="${esc(key)}">${esc(key)}</td>` +
|
||
`<td><textarea class="trans-val" data-lang="fr" rows="1" spellcheck="false">${esc(fr)}</textarea></td>` +
|
||
`<td><textarea class="trans-val" data-lang="en" rows="1" spellcheck="false">${esc(en)}</textarea></td>` +
|
||
`<td class="col-actions"><button type="button" class="btn-danger btn-sm trans-del" title="Supprimer la clé">Supprimer</button></td>`;
|
||
tr.querySelectorAll("textarea").forEach((ta) => {
|
||
ta.addEventListener("input", () => autoGrow(ta));
|
||
autoGrow(ta);
|
||
});
|
||
tr.querySelector(".trans-del").addEventListener("click", () => {
|
||
tr.remove();
|
||
setTransMsg("Modifications non enregistrées.", "ok");
|
||
updateTransCount();
|
||
});
|
||
return tr;
|
||
}
|
||
|
||
function applyTransFilter() {
|
||
const q = $("trans-filter").value.trim().toLowerCase();
|
||
for (const tr of $("trans-table").querySelectorAll("tbody tr")) {
|
||
tr.hidden = !!(q && !tr.dataset.key.includes(q));
|
||
}
|
||
updateTransCount();
|
||
}
|
||
|
||
function updateTransCount() {
|
||
const total = transKeys.length;
|
||
const visible = $("trans-table").querySelectorAll("tbody tr:not([hidden])").length;
|
||
$("trans-meta").textContent = visible + " / " + total + " clés";
|
||
}
|
||
|
||
async function loadTranslations() {
|
||
const r = await api(API.i18n);
|
||
if (r.status !== 200) return;
|
||
const dict = r.data || { fr: {}, en: {} };
|
||
transKeys = Array.from(
|
||
new Set([...Object.keys(dict.fr || {}), ...Object.keys(dict.en || {})])
|
||
).sort();
|
||
const tbody = $("trans-table").querySelector("tbody");
|
||
tbody.innerHTML = "";
|
||
const frag = document.createDocumentFragment();
|
||
for (const key of transKeys) {
|
||
frag.appendChild(transRow(key, (dict.fr || {})[key] || "", (dict.en || {})[key] || ""));
|
||
}
|
||
tbody.appendChild(frag);
|
||
updateTransCount();
|
||
setTransMsg("", "");
|
||
}
|
||
|
||
async function saveTranslations() {
|
||
const dict = { fr: {}, en: {} };
|
||
for (const tr of $("trans-table").querySelectorAll("tbody tr")) {
|
||
const key = tr.dataset.key;
|
||
dict.fr[key] = tr.querySelector('[data-lang="fr"]').value;
|
||
dict.en[key] = tr.querySelector('[data-lang="en"]').value;
|
||
}
|
||
setTransMsg("", "");
|
||
$("trans-save").disabled = true;
|
||
try {
|
||
const r = await api(API.i18n, { method: "PUT", body: JSON.stringify(dict) });
|
||
if (r.status !== 200) {
|
||
setTransMsg(r.data && r.data.error ? r.data.error : "Erreur", "err");
|
||
return;
|
||
}
|
||
setTransMsg("Traductions enregistrées.", "ok");
|
||
loadTranslations();
|
||
} catch (err) {
|
||
if (err && err.message === "Session expirée") return;
|
||
setTransMsg("Erreur réseau", "err");
|
||
} finally {
|
||
$("trans-save").disabled = false;
|
||
}
|
||
}
|
||
|
||
function addTransKey() {
|
||
const key = window.prompt("Clé de traduction (ex : faq.q9) :");
|
||
if (!key) return;
|
||
const k = key.trim();
|
||
if (!k) return;
|
||
const tbody = $("trans-table").querySelector("tbody");
|
||
if ([...tbody.children].some((tr) => tr.dataset.key === k)) {
|
||
setTransMsg("Cette clé existe déjà.", "err");
|
||
return;
|
||
}
|
||
tbody.appendChild(transRow(k, "", ""));
|
||
transKeys.push(k);
|
||
transKeys.sort();
|
||
$("trans-filter").value = "";
|
||
applyTransFilter();
|
||
setTransMsg("Clé ajoutée, enregistrez pour appliquer.", "ok");
|
||
}
|
||
|
||
/* ---------- Règles Discord ---------- */
|
||
|
||
async function loadRules() {
|
||
const r = await api(API.rules);
|
||
if (r.status !== 200) return;
|
||
$("rules-content").value = (r.data && r.data.content) || "";
|
||
const len = (r.data && r.data.length) || 0;
|
||
$("rules-meta").textContent = len ? len + " caractères" : "";
|
||
setRulesMsg("", "");
|
||
}
|
||
|
||
function setRulesMsg(text, cls) {
|
||
const m = $("rules-msg");
|
||
m.textContent = text;
|
||
m.className = "msg" + (cls ? " " + cls : "");
|
||
}
|
||
|
||
async function saveRules() {
|
||
const content = $("rules-content").value;
|
||
setRulesMsg("", "");
|
||
$("rules-save").disabled = true;
|
||
try {
|
||
const r = await api(API.rules, { method: "PUT", body: JSON.stringify({ content }) });
|
||
if (r.status !== 200) {
|
||
setRulesMsg(r.data && r.data.error ? r.data.error : "Erreur", "err");
|
||
return;
|
||
}
|
||
setRulesMsg("Règles enregistrées — le bot les publiera dans la minute.", "ok");
|
||
loadRules();
|
||
} catch (err) {
|
||
if (err && err.message === "Session expirée") return;
|
||
setRulesMsg("Erreur réseau", "err");
|
||
} finally {
|
||
$("rules-save").disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------- Honeypot ---------- */
|
||
|
||
function hpEndpointsHtml(endpoints) {
|
||
const top = Object.entries(endpoints || {})
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3);
|
||
if (top.length === 0) return '<span class="muted">—</span>';
|
||
return top
|
||
.map(([p, c]) => `<span class="hp-end" title="${esc(p)}">${esc(p)} ×${c}</span>`)
|
||
.join(" ");
|
||
}
|
||
|
||
async function loadHoneypot() {
|
||
const r = await api(API.honeypot);
|
||
if (r.status !== 200) return;
|
||
const d = r.data || {};
|
||
const ips = d.ips || [];
|
||
const total = d.total || 0;
|
||
const banned = ips.filter((x) => x.banned).length;
|
||
$("honeypot-meta").textContent =
|
||
total + " IP piégées" + (banned ? " · " + banned + " bannie(s)" : "") + " (max " + (d.max || "-") + ")";
|
||
const box = $("honeypot-list");
|
||
if (ips.length === 0) {
|
||
box.innerHTML = '<p class="meta">Aucune IP piégée pour le moment.</p>';
|
||
return;
|
||
}
|
||
box.innerHTML =
|
||
'<table><thead><tr><th>IP</th><th>Hits</th><th>Chemins</th><th>Premier</th><th>Dernier</th><th>Statut</th><th></th></tr></thead><tbody>' +
|
||
ips
|
||
.map((e) => {
|
||
const badge = e.banned ? '<span class="badge-ban">Bannie</span>' : '<span class="muted">Active</span>';
|
||
return (
|
||
"<tr>" +
|
||
`<td class="ip">${esc(e.ip)}</td>` +
|
||
`<td>${e.count}</td>` +
|
||
`<td class="hp-ends">${hpEndpointsHtml(e.endpoints)}</td>` +
|
||
`<td>${fmtDate(Math.floor(e.first / 1000))}</td>` +
|
||
`<td>${fmtDate(Math.floor(e.last / 1000))}</td>` +
|
||
`<td>${badge}</td>` +
|
||
`<td class="col-actions"><button type="button" class="btn-ghost btn-sm" data-hp="${esc(e.ip)}" data-ban="${e.banned ? "0" : "1"}">${e.banned ? "Débannir" : "Bannir"}</button></td>` +
|
||
"</tr>"
|
||
);
|
||
})
|
||
.join("") +
|
||
"</tbody></table>";
|
||
}
|
||
|
||
async function setHoneypotBan(ip, ban) {
|
||
const r = await api(API.honeypot, { method: "POST", body: JSON.stringify({ action: ban ? "ban" : "unban", ip }) });
|
||
if (r.status !== 200) return;
|
||
loadHoneypot();
|
||
}
|
||
|
||
async function clearHoneypot() {
|
||
if (!confirm("Vider toute la liste honeypot ?")) return;
|
||
const r = await api(API.honeypot, { method: "POST", body: JSON.stringify({ action: "clear" }) });
|
||
if (r.status !== 200) return;
|
||
loadHoneypot();
|
||
}
|
||
|
||
/* ---------- Actualisation ---------- */
|
||
|
||
async function refreshAll() {
|
||
try {
|
||
await loadDashboard();
|
||
$("updated").textContent = "à jour " + new Date().toLocaleTimeString("fr-FR", { hour12: false });
|
||
} catch (err) {
|
||
if (err && err.message === "Session expirée") return;
|
||
$("updated").textContent = "erreur de rafraîchissement";
|
||
}
|
||
}
|
||
|
||
function startAutoRefresh() {
|
||
stopAutoRefresh();
|
||
autoTimer = setInterval(refreshAll, REFRESH_MS);
|
||
logTimer = setInterval(() => {
|
||
if (!$("tab-logs").hidden) loadLogs();
|
||
if (!$("tab-system").hidden) loadSystem();
|
||
if (!$("tab-zmap").hidden) loadZmap();
|
||
}, REFRESH_MS);
|
||
refreshAll();
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (autoTimer) clearInterval(autoTimer);
|
||
if (logTimer) clearInterval(logTimer);
|
||
autoTimer = null;
|
||
logTimer = null;
|
||
}
|
||
|
||
/* ---------- Redémarrage ---------- */
|
||
|
||
async function doRestart(service) {
|
||
const label = service === "app" ? "l'application" : "Caddy";
|
||
if (!window.confirm(`Redémarrer ${label} ?`)) return;
|
||
const btn = service === "app" ? $("restart-app") : $("restart-caddy");
|
||
btn.disabled = true;
|
||
btn.textContent = "Redémarrage…";
|
||
try {
|
||
const r = await api(API.restart, { method: "POST", body: JSON.stringify({ service }) });
|
||
if (r.status !== 200) {
|
||
window.alert(r.data && r.data.error ? r.data.error : "Erreur");
|
||
return;
|
||
}
|
||
if (service === "app") {
|
||
setTimeout(() => {
|
||
window.alert("Application redémarrée. Votre session est expirée, reconnectez-vous.");
|
||
clearSession();
|
||
}, 2000);
|
||
} else {
|
||
setTimeout(() => window.alert("Caddy redémarré."), 2000);
|
||
}
|
||
} catch (err) {
|
||
if (err && err.message === "Session expirée") return;
|
||
window.alert("Erreur réseau");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = label === "l'application" ? "Redémarrer l'application" : "Redémarrer Caddy";
|
||
}
|
||
}
|
||
|
||
/* ---------- Câblage ---------- */
|
||
|
||
$("auth-form").addEventListener("submit", submitAuth);
|
||
$("toggle-pw").addEventListener("click", () => {
|
||
const input = $("password");
|
||
input.type = input.type === "password" ? "text" : "password";
|
||
});
|
||
|
||
document.querySelectorAll(".nav-item").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.tab)));
|
||
$("refresh").addEventListener("click", refreshAll);
|
||
$("logout").addEventListener("click", () => {
|
||
stopAutoRefresh();
|
||
clearSession();
|
||
setMsg("", "");
|
||
});
|
||
$("autorefresh").addEventListener("change", (e) => {
|
||
if (e.target.checked) startAutoRefresh();
|
||
else stopAutoRefresh();
|
||
});
|
||
$("restart-app").addEventListener("click", () => doRestart("app"));
|
||
$("restart-caddy").addEventListener("click", () => doRestart("caddy"));
|
||
$("log-filter").addEventListener("input", () => {
|
||
if (logFilterTimer) clearTimeout(logFilterTimer);
|
||
logFilterTimer = setTimeout(loadLogs, 350);
|
||
});
|
||
$("log-status").addEventListener("change", loadLogs);
|
||
$("log-exclude").addEventListener("click", () => setLogExclude(!logExclude));
|
||
$("article-new").addEventListener("click", () => openArticleEditor());
|
||
$("article-form").addEventListener("submit", saveArticle);
|
||
$("art-cancel").addEventListener("click", () => {
|
||
$("article-editor").hidden = true;
|
||
setArticleMsg("", "");
|
||
});
|
||
$("art-delete").addEventListener("click", () => {
|
||
if (articleSlug) deleteArticle(articleSlug);
|
||
});
|
||
$("articles-list").addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-action]");
|
||
if (!btn) return;
|
||
const slug = btn.dataset.slug;
|
||
if (btn.dataset.action === "edit") openArticleEditor(slug);
|
||
else if (btn.dataset.action === "view") window.open("/actualites/" + slug + "/", "_blank", "noopener");
|
||
else if (btn.dataset.action === "delete") deleteArticle(slug);
|
||
});
|
||
$("trans-save").addEventListener("click", saveTranslations);
|
||
$("trans-add").addEventListener("click", addTransKey);
|
||
$("trans-filter").addEventListener("input", applyTransFilter);
|
||
$("rules-save").addEventListener("click", saveRules);
|
||
$("rules-reload").addEventListener("click", loadRules);
|
||
$("honeypot-refresh").addEventListener("click", loadHoneypot);
|
||
$("honeypot-clear").addEventListener("click", clearHoneypot);
|
||
$("honeypot-list").addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-hp]");
|
||
if (!btn) return;
|
||
setHoneypotBan(btn.dataset.hp, btn.dataset.ban === "1");
|
||
});
|
||
$("art-copy-from-en").addEventListener("click", () => copyLangFields("en", "fr"));
|
||
$("art-copy-from-fr").addEventListener("click", () => copyLangFields("fr", "en"));
|
||
document.querySelectorAll("[data-preview]").forEach((b) =>
|
||
b.addEventListener("click", () => togglePreview(b.dataset.preview))
|
||
);
|
||
|
||
initAuth().catch(() => {
|
||
$("login").hidden = false;
|
||
setMsg("Erreur réseau", "err");
|
||
});
|
||
|
||
renderLogExclude();
|