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:
parent
a6d7bc2c8a
commit
9857a92031
6 changed files with 223 additions and 29 deletions
68
README.md
68
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 <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 :
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2.0.0
|
||||
2.1.0
|
||||
|
|
|
|||
78
app/app.py
78
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"])
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue