zektyc/public/z-panel/admin.js
riricdev 8e7ec41f18 chore: checkpoint initial — Zektyc dataset, avant humanisation
- site vitrine + fingerprint test + zmap + z-panel
- remplacement duckdns -> riricdev.tail5ea5cd.ts.net (canonical/og/alternates/docs)
- suppression du token DuckDNS (plus utilisé)
2026-09-06 13:03:25 +02:00

1035 lines
35 KiB
JavaScript
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.

"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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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();