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 %}
+
+
+
+
+ {% if not entries %}
+ Aucune erreur 404 dans les logs récents.
+ {% else %}
+
+
+
+ |
+ Chemin |
+ Requêtes |
+ Dernière vue |
+ IPs |
+ |
+
+
+
+ {% for path, info in entries %}
+
+ |
+ ●
+ |
+ {{ path }} |
+ {{ info.count }} |
+ {{ info.last_seen.strftime('%d/%m/%Y %H:%M') }} |
+ {{ info.ips | length }} |
+
+
+ |
+
+
+ |
+
+ Chargement…
+
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+{% if ignored_ips %}
+
+ IPs ignorées
+
+ {% for ip in ignored_ips %}
+
+ {{ ip }}
+
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% endif %}
+
+
+{% endblock %}