From 9857a920317820a469a9a6a48f4768fd7ac7ee81 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Sun, 20 Sep 2026 08:39:05 +0200 Subject: [PATCH] =?UTF-8?q?Parcourir=20:=20cr=C3=A9er=20un=20dossier=20dep?= =?UTF-8?q?uis=20le=20fil=20d'Ariane=20(v2.1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bouton « 📁 Nouveau dossier » ouvre une saisie en ligne à droite du fil d'Ariane (Entrée valide, Échap annule) et crée le sous-dossier dans le répertoire affiché. Endpoint POST /mkdir, réservé aux admins : nom nettoyé par secure_filename, refus des noms réservés ou cachés (app, scripts, .git…), des traversées de chemin, et des dossiers déjà existants (409). Le README porte aussi les chemins locaux remis à jour après le déménagement du dépôt dans ~/Projects/alpinux.static. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KtgiPNG3inkbM8k7suMtjL --- README.md | 68 ++++++++++++++++++++++++++++-- app/CHANGELOG.md | 8 ++++ app/VERSION | 2 +- app/app.py | 78 +++++++++++++++++++++++++++++------ app/static/app.css | 9 ++++ app/templates/browse.html | 87 ++++++++++++++++++++++++++++++++++----- 6 files changed, 223 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e4f1628..d285f06 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ CDN pour les fichiers binaires et assets statiques de l'association : logos, favicons, images Open Graph. Tableau de bord d'administration accessible sur `https://static.alpinux.org` (authentification AlpID). +Dépôt : [alpinux-static](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-static) — localement `~/Projects/alpinux.static/` (un domaine, un dépôt). + --- ## Flux de publication @@ -19,7 +21,7 @@ Git sert à **versionner et sauvegarder** le code sur Gitea. Il n'est pas dans l ### Modifier les assets CDN (logo, wiki, stats…) ```bash -# 1. Modifier les fichiers dans static/ +# 1. Modifier les fichiers à la racine du dépôt (logo/, wiki/, error/…) # 2. Versionner (optionnel mais recommandé) git add git commit -m "..." @@ -36,7 +38,7 @@ git push # sauvegarde sur gitea.alpinux.org ### Modifier l'app Flask (`app/`) ```bash -# 1. Modifier les fichiers dans static/app/ +# 1. Modifier les fichiers dans app/ # 2. Versionner git add app/ git commit -m "..." @@ -62,7 +64,7 @@ cp .env.example .env |----------|-------------|-------------| | `STATIC_HOST` | oui | Alias SSH ou nom d'hôte (`alpinux.org`) | | `STATIC_PATH` | oui | Chemin absolu du web root sur le serveur | -| `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`static/`) | +| `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`~/Projects/alpinux.static`) | | `STATIC_USER` | non | Login SSH — laisser vide si `~/.ssh/config` définit l'utilisateur | Si l'hôte est déclaré dans `~/.ssh/config` (recommandé), laisser `STATIC_USER` vide. @@ -100,10 +102,18 @@ Authentification SSO via AlpID (Keycloak). | Route | Description | |-------|-------------| | `/` | Tableau de bord — statistiques CDN par dossier | -| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload** | +| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload**, **création de dossier** | | `/search` | Recherche par nom / contenu / date | | `/stats/` | Rapport de trafic GoAccess | +### Création de dossier + +Le bouton « 📁 Nouveau dossier », à droite du fil d'Ariane de `/browse/`, crée un +sous-dossier dans le répertoire affiché (Entrée pour valider, Échap pour annuler). +Le nom est nettoyé par `secure_filename` — accents et espaces sont normalisés. +Sont refusés : les noms réservés ou cachés (`app`, `scripts`, `.git`, …), les +traversées de chemin et les dossiers déjà existants. + ### Upload de fichiers Dans le navigateur (`/browse/`), une zone de dépôt est affichée en bas de chaque dossier. @@ -194,6 +204,56 @@ sudo systemctl enable --now static-cdn --- +## Bannissement fail2ban (`/errors/`) + +### Architecture + +La page `/errors/` permet de bannir des IPs ou des AS entiers. Les bans sont **permanents** (`bantime = -1`). + +| Composant | Valeur | +|-----------|--------| +| Jail fail2ban | `global-blacklist` | +| Backend | `iptables-ipset-net-allports` (ipset `hash:net`, O(1)) | +| Ipset kernel | `f2b-global-blacklist` | +| Persistance bans | Base SQLite fail2ban (`bantime = -1` → jamais expirés) | +| Lecture bans (app) | `sudo /usr/sbin/ipset list f2b-global-blacklist` | +| Écriture bans (app) | `sudo /usr/bin/fail2ban-client set global-blacklist banip/unbanip` | + +> **Pourquoi ipset ?** Avec `iptables-allports`, chaque ban ajoutait une règle individuelle. À partir de ~5 000 entrées, les bans en rafale (ban AS = plusieurs /24) dépassaient le timeout de 60 s. `hash:net` effectue lookup et insertion en O(1) quel que soit le nombre d'entrées. + +### Sudoers + +Fichier : `/etc/sudoers.d/static-cdn-fail2ban` (user `static-cdn`) + +``` +static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client set global-blacklist banip * +static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client set global-blacklist unbanip * +static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client status global-blacklist +static-cdn ALL=(ALL) NOPASSWD: /usr/sbin/ipset list f2b-global-blacklist +``` + +### Configuration fail2ban + +`/etc/fail2ban/jail.local` : + +```ini +[global-blacklist] +enabled = true +filter = global-blacklist +bantime = -1 +action = iptables-ipset-net-allports[name=global-blacklist] +logpath = /etc/fail2ban/badips.list +``` + +Action custom : `/etc/fail2ban/action.d/iptables-ipset-net-allports.conf` +(hérite de `iptables.conf`, crée un ipset `hash:net family inet`) + +### Restauration après redémarrage fail2ban + +Avec `bantime = -1`, fail2ban restaure **tous** les bans de sa DB SQLite au démarrage — il n'y a pas d'expiration. Le redémarrage du service prend quelques secondes même avec 20 000+ entrées (ipset = insertion kernel rapide). + +--- + ## Voir aussi Vue d'ensemble des projets, procédures de déploiement globales et accès : diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index a7d5a71..1c15ff4 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — Alpinux Static +## [2.1.0] — 2026-09-05 + +### Ajouté +- **Parcourir** : bouton « 📁 Nouveau dossier » à droite du fil d'Ariane — saisie en ligne (Entrée pour valider, Échap pour annuler), le dossier est créé dans le répertoire affiché +- Nouveau endpoint `POST /mkdir` : réservé aux admins, nom nettoyé par `secure_filename`, refus des noms réservés/cachés, des traversées de chemin et des doublons (409) + +--- + ## [2.0.0] — 2026-05-06 ### Ajouté diff --git a/app/VERSION b/app/VERSION index 227cea2..7ec1d6d 100644 --- a/app/VERSION +++ b/app/VERSION @@ -1 +1 @@ -2.0.0 +2.1.0 diff --git a/app/app.py b/app/app.py index 1bfe58c..e17abd0 100644 --- a/app/app.py +++ b/app/app.py @@ -589,28 +589,30 @@ def _invalidate_404_cache(): # ── Bannissement fail2ban — helpers ─────────────────────────────────── def _get_banned_ips() -> tuple: - """Returns (set_of_ips, list_of_networks) from fail2ban global-blacklist.""" + """Returns (set_of_ips, list_of_networks) from ipset f2b-global-blacklist.""" global _BANNED_CACHE, _BANNED_CACHE_TS now = time.time() if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL: return _BANNED_CACHE try: r = subprocess.run( - ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"], - capture_output=True, text=True, timeout=30 + ["/usr/bin/sudo", "/usr/sbin/ipset", "list", "f2b-global-blacklist"], + capture_output=True, text=True, timeout=15 ) if r.returncode != 0: - app.logger.error("fail2ban status rc=%d stderr=%s", r.returncode, r.stderr[:500]) + app.logger.error("ipset list rc=%d stderr=%s", r.returncode, r.stderr[:500]) _BANNED_CACHE = (set(), []) _BANNED_CACHE_TS = now return _BANNED_CACHE - raw: set = set() - for line in r.stdout.splitlines(): - if "Banned IP list:" in line: - raw = set(line.split("Banned IP list:")[1].split()) - break ips, nets = set(), [] - for entry in raw: + in_members = False + for line in r.stdout.splitlines(): + if line.startswith("Members:"): + in_members = True + continue + if not in_members or not line.strip(): + continue + entry = line.split()[0] # ignore optional timeout field if "/" in entry: try: nets.append(ipaddress.ip_network(entry, strict=False)) @@ -618,13 +620,13 @@ def _get_banned_ips() -> tuple: pass else: ips.add(entry) - app.logger.info("fail2ban: %d IPs, %d nets loaded", len(ips), len(nets)) + app.logger.info("ipset: %d IPs, %d nets loaded", len(ips), len(nets)) _BANNED_CACHE = (ips, nets) except subprocess.TimeoutExpired: - app.logger.error("fail2ban status timed out after 30s") + app.logger.error("ipset list timed out after 15s") _BANNED_CACHE = (set(), []) except Exception as e: - app.logger.error("fail2ban status error: %s", e) + app.logger.error("ipset list error: %s", e) _BANNED_CACHE = (set(), []) _BANNED_CACHE_TS = now return _BANNED_CACHE @@ -1609,6 +1611,56 @@ def rename_file(): }) +# ── Création de dossier ────────────────────────────────────────────── + +@app.route("/mkdir", methods=["POST"]) +def make_dir(): + redir = _require_admin() + if redir: + return redir + + subpath = request.form.get("path", "").strip() + name = request.form.get("name", "").strip() + + if not name: + return jsonify({"error": "Nom manquant"}), 400 + + if "/" in name or "\\" in name or name in (".", ".."): + return jsonify({"error": "Nom invalide"}), 400 + + name = secure_filename(name) + if not name or name.startswith(".") or name in _HIDDEN: + return jsonify({"error": "Nom invalide après nettoyage"}), 400 + + if subpath: + parts = Path(subpath).parts + if parts and (parts[0] in _HIDDEN or parts[0].startswith(".")): + return jsonify({"error": "Accès refusé"}), 403 + + parent = _safe_path(subpath) if subpath else ASSETS_ROOT + if not parent.is_dir(): + return jsonify({"error": "Dossier parent introuvable"}), 404 + + dest = parent / name + if not dest.is_relative_to(ASSETS_ROOT): + return jsonify({"error": "Destination invalide"}), 400 + + if dest.exists(): + return jsonify({"error": f"« {name} » existe déjà"}), 409 + + try: + dest.mkdir() + except OSError as exc: + return jsonify({"error": f"Création impossible : {exc.strerror}"}), 500 + + new_path = str(dest.relative_to(ASSETS_ROOT)) + return jsonify({ + "name": name, + "path": new_path, + "browse_url": url_for("browse", subpath=new_path), + }) + + # ── Vérification des conflits avant upload ─────────────────────────── @app.route("/check-upload", methods=["POST"]) diff --git a/app/static/app.css b/app/static/app.css index 29da5ad..bf441a9 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -91,6 +91,15 @@ html[data-cw="1600"] main { max-width: 1600px; } .breadcrumb .sep { color: var(--muted); } .breadcrumb .current { color: var(--text); font-weight: 600; } +/* ── Barre du dossier courant + nouveau dossier ───────────────── */ +.browse-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .8rem; } +.browse-head .breadcrumb { margin-bottom: 0; } +.mkdir { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } +.mkdir-form { display: none; align-items: center; gap: .25rem; } +.mkdir-form:not([hidden]) { display: flex; } +.mkdir-form .rename-input { min-width: 180px; } +.mkdir-error { font-size: .8rem; color: #b91c1c; } + /* ── Tableau de fichiers ──────────────────────────────────────── */ .file-table { width: 100%; border-collapse: collapse; font-size: .9rem; } .file-table th { text-align: left; padding: .55rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; } diff --git a/app/templates/browse.html b/app/templates/browse.html index 8227873..859e8d4 100644 --- a/app/templates/browse.html +++ b/app/templates/browse.html @@ -5,17 +5,30 @@
- +
+ + +
+ + + +
+
{% if entries or subpath %} {% set mode = 'browse' %} @@ -114,6 +127,58 @@
+ +