Compare commits
No commits in common. "bd4f4c62ae888d4aed4c35233457d9f5168047f7" and "a6d7bc2c8a2d724b57f834348b80377943ee8270" have entirely different histories.
bd4f4c62ae
...
a6d7bc2c8a
9 changed files with 31 additions and 226 deletions
|
|
@ -13,4 +13,4 @@ STATIC_HOST=alpinux.org
|
||||||
STATIC_PATH=/var/www/clients/client1/web17/web
|
STATIC_PATH=/var/www/clients/client1/web17/web
|
||||||
|
|
||||||
# Répertoire local source (racine du repo alpinux-static)
|
# Répertoire local source (racine du repo alpinux-static)
|
||||||
LOCAL_ASSETS_DIR=<chemin absolu vers alpinux.static>
|
LOCAL_ASSETS_DIR=<chemin absolu vers org.alpinux.owni/static>
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1 @@
|
||||||
.env
|
.env
|
||||||
__pycache__/
|
|
||||||
|
|
|
||||||
68
README.md
68
README.md
|
|
@ -3,8 +3,6 @@
|
||||||
CDN pour les fichiers binaires et assets statiques de l'association : logos, favicons, images Open Graph.
|
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).
|
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
|
## Flux de publication
|
||||||
|
|
@ -21,7 +19,7 @@ Git sert à **versionner et sauvegarder** le code sur Gitea. Il n'est pas dans l
|
||||||
### Modifier les assets CDN (logo, wiki, stats…)
|
### Modifier les assets CDN (logo, wiki, stats…)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Modifier les fichiers à la racine du dépôt (logo/, wiki/, error/…)
|
# 1. Modifier les fichiers dans static/
|
||||||
# 2. Versionner (optionnel mais recommandé)
|
# 2. Versionner (optionnel mais recommandé)
|
||||||
git add <fichiers>
|
git add <fichiers>
|
||||||
git commit -m "..."
|
git commit -m "..."
|
||||||
|
|
@ -38,7 +36,7 @@ git push # sauvegarde sur gitea.alpinux.org
|
||||||
### Modifier l'app Flask (`app/`)
|
### Modifier l'app Flask (`app/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Modifier les fichiers dans app/
|
# 1. Modifier les fichiers dans static/app/
|
||||||
# 2. Versionner
|
# 2. Versionner
|
||||||
git add app/
|
git add app/
|
||||||
git commit -m "..."
|
git commit -m "..."
|
||||||
|
|
@ -64,7 +62,7 @@ cp .env.example .env
|
||||||
|----------|-------------|-------------|
|
|----------|-------------|-------------|
|
||||||
| `STATIC_HOST` | oui | Alias SSH ou nom d'hôte (`alpinux.org`) |
|
| `STATIC_HOST` | oui | Alias SSH ou nom d'hôte (`alpinux.org`) |
|
||||||
| `STATIC_PATH` | oui | Chemin absolu du web root sur le serveur |
|
| `STATIC_PATH` | oui | Chemin absolu du web root sur le serveur |
|
||||||
| `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`~/Projects/alpinux.static`) |
|
| `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`static/`) |
|
||||||
| `STATIC_USER` | non | Login SSH — laisser vide si `~/.ssh/config` définit l'utilisateur |
|
| `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.
|
Si l'hôte est déclaré dans `~/.ssh/config` (recommandé), laisser `STATIC_USER` vide.
|
||||||
|
|
@ -102,18 +100,10 @@ Authentification SSO via AlpID (Keycloak).
|
||||||
| Route | Description |
|
| Route | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `/` | Tableau de bord — statistiques CDN par dossier |
|
| `/` | Tableau de bord — statistiques CDN par dossier |
|
||||||
| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload**, **création de dossier** |
|
| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload** |
|
||||||
| `/search` | Recherche par nom / contenu / date |
|
| `/search` | Recherche par nom / contenu / date |
|
||||||
| `/stats/` | Rapport de trafic GoAccess |
|
| `/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
|
### Upload de fichiers
|
||||||
|
|
||||||
Dans le navigateur (`/browse/`), une zone de dépôt est affichée en bas de chaque dossier.
|
Dans le navigateur (`/browse/`), une zone de dépôt est affichée en bas de chaque dossier.
|
||||||
|
|
@ -204,56 +194,6 @@ 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
|
## Voir aussi
|
||||||
|
|
||||||
Vue d'ensemble des projets, procédures de déploiement globales et accès :
|
Vue d'ensemble des projets, procédures de déploiement globales et accès :
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ ADMIN_EMAILS=cedric.alpinux@acemail.fr
|
||||||
|
|
||||||
# ── Répertoire racine du CDN à parcourir ──────────────────────────────
|
# ── Répertoire racine du CDN à parcourir ──────────────────────────────
|
||||||
# Production : /var/www/clients/client1/web17/web
|
# Production : /var/www/clients/client1/web17/web
|
||||||
# Dev local : chemin absolu vers alpinux.static
|
# Dev local : chemin absolu vers org.alpinux.owni/static
|
||||||
ASSETS_ROOT=<chemin absolu>
|
ASSETS_ROOT=<chemin absolu>
|
||||||
|
|
||||||
# ── Statistiques GoAccess ─────────────────────────────────────────────
|
# ── Statistiques GoAccess ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
# Changelog — Alpinux Static
|
# 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
|
## [2.0.0] — 2026-05-06
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
2.1.0
|
2.0.0
|
||||||
|
|
|
||||||
78
app/app.py
78
app/app.py
|
|
@ -589,30 +589,28 @@ def _invalidate_404_cache():
|
||||||
# ── Bannissement fail2ban — helpers ───────────────────────────────────
|
# ── Bannissement fail2ban — helpers ───────────────────────────────────
|
||||||
|
|
||||||
def _get_banned_ips() -> tuple:
|
def _get_banned_ips() -> tuple:
|
||||||
"""Returns (set_of_ips, list_of_networks) from ipset f2b-global-blacklist."""
|
"""Returns (set_of_ips, list_of_networks) from fail2ban global-blacklist."""
|
||||||
global _BANNED_CACHE, _BANNED_CACHE_TS
|
global _BANNED_CACHE, _BANNED_CACHE_TS
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL:
|
if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL:
|
||||||
return _BANNED_CACHE
|
return _BANNED_CACHE
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["/usr/bin/sudo", "/usr/sbin/ipset", "list", "f2b-global-blacklist"],
|
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"],
|
||||||
capture_output=True, text=True, timeout=15
|
capture_output=True, text=True, timeout=30
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
app.logger.error("ipset list rc=%d stderr=%s", r.returncode, r.stderr[:500])
|
app.logger.error("fail2ban status rc=%d stderr=%s", r.returncode, r.stderr[:500])
|
||||||
_BANNED_CACHE = (set(), [])
|
_BANNED_CACHE = (set(), [])
|
||||||
_BANNED_CACHE_TS = now
|
_BANNED_CACHE_TS = now
|
||||||
return _BANNED_CACHE
|
return _BANNED_CACHE
|
||||||
ips, nets = set(), []
|
raw: set = set()
|
||||||
in_members = False
|
|
||||||
for line in r.stdout.splitlines():
|
for line in r.stdout.splitlines():
|
||||||
if line.startswith("Members:"):
|
if "Banned IP list:" in line:
|
||||||
in_members = True
|
raw = set(line.split("Banned IP list:")[1].split())
|
||||||
continue
|
break
|
||||||
if not in_members or not line.strip():
|
ips, nets = set(), []
|
||||||
continue
|
for entry in raw:
|
||||||
entry = line.split()[0] # ignore optional timeout field
|
|
||||||
if "/" in entry:
|
if "/" in entry:
|
||||||
try:
|
try:
|
||||||
nets.append(ipaddress.ip_network(entry, strict=False))
|
nets.append(ipaddress.ip_network(entry, strict=False))
|
||||||
|
|
@ -620,13 +618,13 @@ def _get_banned_ips() -> tuple:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
ips.add(entry)
|
ips.add(entry)
|
||||||
app.logger.info("ipset: %d IPs, %d nets loaded", len(ips), len(nets))
|
app.logger.info("fail2ban: %d IPs, %d nets loaded", len(ips), len(nets))
|
||||||
_BANNED_CACHE = (ips, nets)
|
_BANNED_CACHE = (ips, nets)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app.logger.error("ipset list timed out after 15s")
|
app.logger.error("fail2ban status timed out after 30s")
|
||||||
_BANNED_CACHE = (set(), [])
|
_BANNED_CACHE = (set(), [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error("ipset list error: %s", e)
|
app.logger.error("fail2ban status error: %s", e)
|
||||||
_BANNED_CACHE = (set(), [])
|
_BANNED_CACHE = (set(), [])
|
||||||
_BANNED_CACHE_TS = now
|
_BANNED_CACHE_TS = now
|
||||||
return _BANNED_CACHE
|
return _BANNED_CACHE
|
||||||
|
|
@ -1611,56 +1609,6 @@ 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 ───────────────────────────
|
# ── Vérification des conflits avant upload ───────────────────────────
|
||||||
|
|
||||||
@app.route("/check-upload", methods=["POST"])
|
@app.route("/check-upload", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -91,15 +91,6 @@ html[data-cw="1600"] main { max-width: 1600px; }
|
||||||
.breadcrumb .sep { color: var(--muted); }
|
.breadcrumb .sep { color: var(--muted); }
|
||||||
.breadcrumb .current { color: var(--text); font-weight: 600; }
|
.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 ──────────────────────────────────────── */
|
/* ── Tableau de fichiers ──────────────────────────────────────── */
|
||||||
.file-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
.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; }
|
.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,30 +5,17 @@
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
||||||
<div class="browse-head">
|
<nav class="breadcrumb">
|
||||||
<nav class="breadcrumb">
|
<a href="{{ url_for('browse') }}">CDN</a>
|
||||||
<a href="{{ url_for('browse') }}">CDN</a>
|
{% for crumb in breadcrumb %}
|
||||||
{% for crumb in breadcrumb %}
|
<span class="sep">/</span>
|
||||||
<span class="sep">/</span>
|
{% if loop.last %}
|
||||||
{% if loop.last %}
|
<span class="current">{{ crumb.name }}</span>
|
||||||
<span class="current">{{ crumb.name }}</span>
|
{% else %}
|
||||||
{% else %}
|
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
|
||||||
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
|
{% endif %}
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
{% endfor %}
|
</nav>
|
||||||
</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 %}
|
{% if entries or subpath %}
|
||||||
{% set mode = 'browse' %}
|
{% set mode = 'browse' %}
|
||||||
|
|
@ -127,58 +114,6 @@
|
||||||
</script>
|
</script>
|
||||||
</section>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue