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.
|
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
|
||||||
|
|
@ -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…)
|
### Modifier les assets CDN (logo, wiki, stats…)
|
||||||
|
|
||||||
```bash
|
```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é)
|
# 2. Versionner (optionnel mais recommandé)
|
||||||
git add <fichiers>
|
git add <fichiers>
|
||||||
git commit -m "..."
|
git commit -m "..."
|
||||||
|
|
@ -36,7 +38,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 static/app/
|
# 1. Modifier les fichiers dans app/
|
||||||
# 2. Versionner
|
# 2. Versionner
|
||||||
git add app/
|
git add app/
|
||||||
git commit -m "..."
|
git commit -m "..."
|
||||||
|
|
@ -62,7 +64,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 (`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 |
|
| `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.
|
||||||
|
|
@ -100,10 +102,18 @@ 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** |
|
| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload**, **création de dossier** |
|
||||||
| `/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.
|
||||||
|
|
@ -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
|
## 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 :
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
# 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.0.0
|
2.1.0
|
||||||
|
|
|
||||||
78
app/app.py
78
app/app.py
|
|
@ -589,28 +589,30 @@ 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 fail2ban global-blacklist."""
|
"""Returns (set_of_ips, list_of_networks) from ipset f2b-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/bin/fail2ban-client", "status", "global-blacklist"],
|
["/usr/bin/sudo", "/usr/sbin/ipset", "list", "f2b-global-blacklist"],
|
||||||
capture_output=True, text=True, timeout=30
|
capture_output=True, text=True, timeout=15
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
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 = (set(), [])
|
||||||
_BANNED_CACHE_TS = now
|
_BANNED_CACHE_TS = now
|
||||||
return _BANNED_CACHE
|
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(), []
|
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:
|
if "/" in entry:
|
||||||
try:
|
try:
|
||||||
nets.append(ipaddress.ip_network(entry, strict=False))
|
nets.append(ipaddress.ip_network(entry, strict=False))
|
||||||
|
|
@ -618,13 +620,13 @@ def _get_banned_ips() -> tuple:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
ips.add(entry)
|
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)
|
_BANNED_CACHE = (ips, nets)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app.logger.error("fail2ban status timed out after 30s")
|
app.logger.error("ipset list timed out after 15s")
|
||||||
_BANNED_CACHE = (set(), [])
|
_BANNED_CACHE = (set(), [])
|
||||||
except Exception as e:
|
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 = (set(), [])
|
||||||
_BANNED_CACHE_TS = now
|
_BANNED_CACHE_TS = now
|
||||||
return _BANNED_CACHE
|
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 ───────────────────────────
|
# ── Vérification des conflits avant upload ───────────────────────────
|
||||||
|
|
||||||
@app.route("/check-upload", methods=["POST"])
|
@app.route("/check-upload", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ 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,17 +5,30 @@
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
||||||
<nav class="breadcrumb">
|
<div class="browse-head">
|
||||||
<a href="{{ url_for('browse') }}">CDN</a>
|
<nav class="breadcrumb">
|
||||||
{% for crumb in breadcrumb %}
|
<a href="{{ url_for('browse') }}">CDN</a>
|
||||||
<span class="sep">/</span>
|
{% for crumb in breadcrumb %}
|
||||||
{% if loop.last %}
|
<span class="sep">/</span>
|
||||||
<span class="current">{{ crumb.name }}</span>
|
{% if loop.last %}
|
||||||
{% else %}
|
<span class="current">{{ crumb.name }}</span>
|
||||||
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
|
{% else %}
|
||||||
{% endif %}
|
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</nav>
|
{% 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 %}
|
{% if entries or subpath %}
|
||||||
{% set mode = 'browse' %}
|
{% set mode = 'browse' %}
|
||||||
|
|
@ -114,6 +127,58 @@
|
||||||
</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