Parcourir : créer un dossier depuis le fil d'Ariane (v2.1.0)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtgiPNG3inkbM8k7suMtjL
This commit is contained in:
Alpinux 2026-09-20 08:39:05 +02:00
parent a6d7bc2c8a
commit 9857a92031
6 changed files with 223 additions and 29 deletions

View file

@ -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 <fichiers>
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 :

View file

@ -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é

View file

@ -1 +1 @@
2.0.0
2.1.0

View file

@ -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"])

View file

@ -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; }

View file

@ -5,17 +5,30 @@
<section class="card">
<nav class="breadcrumb">
<a href="{{ url_for('browse') }}">CDN</a>
{% for crumb in breadcrumb %}
<span class="sep">/</span>
{% if loop.last %}
<span class="current">{{ crumb.name }}</span>
{% else %}
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
{% endif %}
{% endfor %}
</nav>
<div class="browse-head">
<nav class="breadcrumb">
<a href="{{ url_for('browse') }}">CDN</a>
{% for crumb in breadcrumb %}
<span class="sep">/</span>
{% if loop.last %}
<span class="current">{{ crumb.name }}</span>
{% else %}
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
{% endif %}
{% endfor %}
</nav>
<div class="mkdir">
<button type="button" class="btn-sm" id="mkdir-open">📁 Nouveau dossier</button>
<div class="mkdir-form" id="mkdir-form" hidden>
<input type="text" class="rename-input" id="mkdir-input"
placeholder="Nom du dossier" autocomplete="off" maxlength="100">
<button type="button" class="btn-rename-ok" id="mkdir-ok" title="Créer">✓</button>
<button type="button" class="btn-rename-cancel" id="mkdir-cancel" title="Annuler">✕</button>
</div>
<span class="mkdir-error" id="mkdir-error"></span>
</div>
</div>
{% if entries or subpath %}
{% set mode = 'browse' %}
@ -114,6 +127,58 @@
</script>
</section>
<script>
(function () {
const open = document.getElementById('mkdir-open');
const form = document.getElementById('mkdir-form');
const inp = document.getElementById('mkdir-input');
const err = document.getElementById('mkdir-error');
const PATH = {{ subpath | tojson }};
function close() {
form.hidden = true;
open.hidden = false;
err.textContent = '';
inp.value = '';
}
open.addEventListener('click', () => {
open.hidden = true;
form.hidden = false;
inp.focus();
});
document.getElementById('mkdir-cancel').addEventListener('click', close);
async function create() {
const name = inp.value.trim();
if (!name) { close(); return; }
err.textContent = '';
const fd = new FormData();
fd.append('path', PATH);
fd.append('name', name);
try {
const resp = await fetch({{ url_for('make_dir') | tojson }}, { method: 'POST', body: fd });
const data = await resp.json();
if (data.error) {
err.textContent = data.error;
inp.focus(); inp.select();
return;
}
location.reload();
} catch (_) {
err.textContent = 'Création impossible';
}
}
document.getElementById('mkdir-ok').addEventListener('click', create);
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); create(); }
if (ev.key === 'Escape') close();
});
})();
</script>
<script>
(function () {
function escHtml(s) {