From 0513afdbb459cb54af23df1ba7aa7247ad7503ab Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 12:57:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20page=20Erreurs=20404=20=E2=80=94=20logs?= =?UTF-8?q?,=20d=C3=A9tail=20IP/date/referer,=20ignore,=20ban=20fail2ban?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Onglet "Erreurs" dans la navigation - Analyse des logs Apache des 7 derniers jours (.gz inclus) - Tableau trié par nombre de requêtes avec badge statut (résolu/actif) - Détail AJAX par chemin : IPs, compteurs, referers - Vérification live au clic sur le point de statut - Ignorer une IP (persisté dans ignored_ips.json, cache invalidé) - Bannir une IP via fail2ban-client (global-blacklist) - Section IPs ignorées avec suppression depuis la page Closes #37 #38 #39 #40 #41 Co-Authored-By: Claude Sonnet 4.6 --- app/app.py | 174 ++++++++++++++++++++++++- app/static/app.css | 48 +++++++ app/templates/base.html | 2 + app/templates/errors_404.html | 235 ++++++++++++++++++++++++++++++++++ 4 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 app/templates/errors_404.html diff --git a/app/app.py b/app/app.py index 0e151b6..97cb7ea 100644 --- a/app/app.py +++ b/app/app.py @@ -1,3 +1,4 @@ +import gzip import io import json import os @@ -5,6 +6,7 @@ import re import shutil import subprocess import threading +import time from pathlib import Path from datetime import datetime, timedelta @@ -50,7 +52,16 @@ try: except Exception: _APP_VERSION = "—" -_CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md" +_CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md" +_IGNORED_IPS_FILE = Path(__file__).parent / "ignored_ips.json" + +_LOG_RE = re.compile( + r'(\S+) \S+ \S+ \[(\d{2}/\w+/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] ' + r'"[A-Z-]+ ([^\s"]+)[^"]*" (\d{3}) \d+ "([^"]*)"' +) +_404_CACHE: dict = {} +_404_CACHE_TS: float = 0 +_404_CACHE_TTL = 300 # 5 min _HIDDEN = frozenset({ @@ -480,6 +491,79 @@ def _parse_changelog(): return sections +# ── Erreurs 404 — helpers ───────────────────────────────────────────── + +def _load_ignored_ips() -> set: + try: + return set(json.loads(_IGNORED_IPS_FILE.read_text()).get("ips", [])) + except Exception: + return set() + +def _save_ignored_ips(ips: set) -> None: + _IGNORED_IPS_FILE.write_text(json.dumps({"ips": sorted(ips)}, indent=2)) + +def _log_files_404(days: int = 7) -> list: + if not STATS_LOG_FILE: + return [] + log_dir = Path(STATS_LOG_FILE).parent + files = [] + for f in sorted(log_dir.glob("*-access.log*"), reverse=True)[:days]: + files.append((f, f.name.endswith(".gz"))) + return files + +def _parse_404s(days: int = 7) -> dict: + global _404_CACHE, _404_CACHE_TS + now = time.time() + if now - _404_CACHE_TS < _404_CACHE_TTL and _404_CACHE: + return _404_CACHE + + ignored = _load_ignored_ips() + results: dict = {} + + for fpath, is_gz in _log_files_404(days): + try: + opener = gzip.open(fpath, "rt", errors="replace") if is_gz \ + else open(fpath, errors="replace") + with opener as f: + for line in f: + m = _LOG_RE.match(line) + if not m: + continue + ip, dt_str, path, status, referer = m.groups() + if status != "404": + continue + if ip in ignored: + continue + path = path.split("?")[0] + try: + dt = datetime.strptime(dt_str, "%d/%b/%Y:%H:%M:%S %z") + except ValueError: + continue + if path not in results: + results[path] = {"count": 0, "last_seen": dt, "ips": {}} + r = results[path] + r["count"] += 1 + if dt > r["last_seen"]: + r["last_seen"] = dt + if ip not in r["ips"]: + r["ips"][ip] = [] + r["ips"][ip].append({"dt": dt, "referer": referer if referer != "-" else ""}) + except Exception: + continue + + _404_CACHE = results + _404_CACHE_TS = now + return results + +def _is_still_404(path: str) -> bool: + clean = path.lstrip("/") + return not (ASSETS_ROOT / clean).exists() + +def _invalidate_404_cache(): + global _404_CACHE_TS + _404_CACHE_TS = 0 + + # ── Navigateur de fichiers ──────────────────────────────────────────── @app.route("/browse/") @@ -733,6 +817,94 @@ def delete_file(): return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) +# ── Erreurs 404 ─────────────────────────────────────────────────────── + +@app.route("/errors/") +def errors_404(): + redir = _require_admin() + if redir: + return redir + data = _parse_404s() + entries = sorted(data.items(), key=lambda x: x[1]["count"], reverse=True) + ignored = _load_ignored_ips() + return render_template("errors_404.html", + entries=entries, + ignored_ips=sorted(ignored), + total=sum(v["count"] for v in data.values()), + log_configured=bool(STATS_LOG_FILE), + ) + + +@app.route("/errors/detail") +def errors_detail(): + redir = _require_admin() + if redir: + return redir + path = request.args.get("path", "") + data = _parse_404s() + entry = data.get(path) + if not entry: + return jsonify({"error": "introuvable"}), 404 + ignored = _load_ignored_ips() + ip_list = [] + for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True): + last = max(h["dt"] for h in hits) + ip_list.append({ + "ip": ip, + "count": len(hits), + "last_seen": last.strftime("%d/%m/%Y %H:%M"), + "ignored": ip in ignored, + "hits": [ + {"dt": h["dt"].strftime("%d/%m/%Y %H:%M"), "referer": h["referer"]} + for h in sorted(hits, key=lambda x: x["dt"], reverse=True)[:30] + ], + }) + return jsonify({ + "path": path, + "count": entry["count"], + "still_404": _is_still_404(path), + "last_seen": entry["last_seen"].strftime("%d/%m/%Y %H:%M"), + "ips": ip_list, + }) + + +@app.route("/errors/ignore", methods=["POST"]) +def errors_ignore(): + redir = _require_admin() + if redir: + return redir + ip = request.form.get("ip", "").strip() + action = request.form.get("action", "add") + if not re.match(r"^[\d\.a-fA-F:]+$", ip): + return jsonify({"error": "IP invalide"}), 400 + ips = _load_ignored_ips() + ips.add(ip) if action == "add" else ips.discard(ip) + _save_ignored_ips(ips) + _invalidate_404_cache() + return jsonify({"ok": True, "action": action, "ip": ip}) + + +@app.route("/errors/ban", methods=["POST"]) +def errors_ban(): + redir = _require_admin() + if redir: + return redir + ip = request.form.get("ip", "").strip() + jail = request.form.get("jail", "global-blacklist") + if not re.match(r"^[\d\.a-fA-F:]+$", ip): + return jsonify({"error": "IP invalide"}), 400 + try: + r = subprocess.run( + ["sudo", "fail2ban-client", "set", jail, "banip", ip], + capture_output=True, text=True, timeout=10 + ) + if r.returncode == 0: + return jsonify({"ok": True, "ip": ip, "jail": jail}) + return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + # ── Corbeille ──────────────────────────────────────────────────────── @app.route("/trash") diff --git a/app/static/app.css b/app/static/app.css index 077462d..b375b69 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -320,3 +320,51 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top: .btn-del--perm { color: #b91c1c; } .btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; } .btn-danger:hover { background: #991b1b; } + +/* ── Erreurs 404 ────────────────────────────────────────────────────── */ +.err-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .5rem; } +.err-header h2 { margin: 0; } +.err-total-badge { font-size: .75rem; font-weight: 400; color: var(--muted); margin-left: .5rem; } +.btn-sm { font-size: .8rem; padding: .3rem .7rem; background: var(--blue-light); color: var(--blue); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; } + +.err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; } +.col-err-status { width: 1.5rem; text-align: center; } +.col-err-count { width: 6rem; text-align: right; } +.col-err-ips { width: 4rem; text-align: right; } + +.err-status-dot { font-size: 1rem; cursor: pointer; transition: color .2s; } +.err-status-dot--active { color: #ef4444; } +.err-status-dot--ok { color: #22c55e; } +.err-status-dot--unk { color: #d1d5db; } + +.err-detail-cell { padding: 0 !important; background: #f8fafd; } +.err-detail-content { padding: .75rem 1.2rem; } +.err-detail-meta { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; } +.err-detail-info { font-size: .82rem; color: var(--muted); } + +.err-badge { font-size: .72rem; font-weight: 700; padding: .15rem .55rem; border-radius: 20px; } +.err-badge--active { background: #fee2e2; color: #991b1b; } +.err-badge--ok { background: #dcfce7; color: #166534; } + +.err-ip-table { width: 100%; border-collapse: collapse; font-size: .85rem; } +.err-ip-table th { text-align: left; padding: .3rem .5rem; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-bottom: 1px solid var(--border); } +.err-ip-table td { padding: .4rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; } +.err-ip-table tr:last-child td { border-bottom: none; } +.err-ip-ignored td { opacity: .45; } +.err-ip { font-size: .82rem; } +.err-ignored-tag { font-size: .68rem; background: #e5e7eb; color: #6b7280; padding: .1rem .35rem; border-radius: 3px; margin-left: .3rem; } + +.err-referers { display: flex; flex-wrap: wrap; gap: .3rem; max-width: 28rem; } +.err-referer { font-size: .75rem; background: var(--blue-light); color: var(--blue); border-radius: 3px; padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 18rem; } + +.btn-ignore-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; } +.btn-ignore-ip:hover, .btn-ignore-ip--active { opacity: 1; } +.btn-ban-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; } +.btn-ban-ip:hover { opacity: 1; } + +.err-loading { color: var(--muted); font-size: .85rem; font-style: italic; } + +.err-ignored-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; } +.err-ignored-chip { display: inline-flex; align-items: center; gap: .35rem; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 20px; padding: .2rem .6rem .2rem .8rem; font-size: .82rem; font-family: monospace; } +.err-ignored-remove { background: none; border: none; cursor: pointer; font-size: .75rem; color: #9ca3af; line-height: 1; padding: 0; transition: color .15s; } +.err-ignored-remove:hover { color: #ef4444; } diff --git a/app/templates/base.html b/app/templates/base.html index 16ec6f3..3c0545b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -23,6 +23,8 @@ {% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir Statistiques + Erreurs Corbeille{% if trash_count %}{{ trash_count }}{% endif %} diff --git a/app/templates/errors_404.html b/app/templates/errors_404.html new file mode 100644 index 0000000..4333b0d --- /dev/null +++ b/app/templates/errors_404.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} +{% block title %}Erreurs 404{% endblock %} + +{% block content %} + +{% if not log_configured %} +
+

STATS_LOG_FILE non configuré — impossible d'analyser les logs.

+
+{% else %} + +
+
+

Erreurs 404 {{ total }} requêtes

+ +
+ + {% if not entries %} +

Aucune erreur 404 dans les logs récents.

+ {% else %} + + + + + + + + + + + + + {% for path, info in entries %} + + + + + + + + + + + + {% endfor %} + +
CheminRequêtesDernière vueIPs
+ + {{ path }}{{ info.count }}{{ info.last_seen.strftime('%d/%m/%Y %H:%M') }}{{ info.ips | length }} + +
+ {% endif %} +
+ +{% if ignored_ips %} +
+

IPs ignorées

+
+ {% for ip in ignored_ips %} + + {{ ip }} + + + {% endfor %} +
+
+{% endif %} + +{% endif %} + + +{% endblock %}