feat: corbeille avec purge automatique 30 jours

- Suppression déplace dans .trash/ (arborescence préservée + .trashinfo)
- /trash : liste, restauration (conflit overwrite/rename), suppression
  définitive, vidage complet
- Purge automatique des fichiers > 30 jours à chaque visite /trash
- Badge rouge dans la nav avec le nombre de fichiers en corbeille
- Extraction du tableau de fichiers en partial _file_table.html
  partagé entre browse et trash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 10:40:35 +02:00
parent dd83e6f36e
commit b3af420d36
6 changed files with 427 additions and 88 deletions

View file

@ -6,7 +6,7 @@ import shutil
import subprocess import subprocess
import threading import threading
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
from urllib.parse import urlencode from urllib.parse import urlencode
from PIL import Image from PIL import Image
@ -33,6 +33,7 @@ oauth.register(
ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(",")) ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(","))
ADMIN_EMAILS = set(e.strip() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip()) ADMIN_EMAILS = set(e.strip() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip())
ASSETS_ROOT = Path(os.environ.get("ASSETS_ROOT", ".")).resolve() ASSETS_ROOT = Path(os.environ.get("ASSETS_ROOT", ".")).resolve()
TRASH_ROOT = ASSETS_ROOT / ".trash"
_alpid_base = os.environ["ALPID_DISCOVERY_URL"].split("/.well-known/")[0] _alpid_base = os.environ["ALPID_DISCOVERY_URL"].split("/.well-known/")[0]
ALPID_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout" ALPID_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout"
@ -172,6 +173,112 @@ def _auto_rename(p: Path) -> Path:
i += 1 i += 1
def _trash_move(target: Path, subpath: str) -> None:
rel = Path(subpath)
trash_dest = TRASH_ROOT / rel
trash_dest.parent.mkdir(parents=True, exist_ok=True)
if trash_dest.exists():
ts = datetime.now().strftime("%Y%m%d%H%M%S")
trash_dest = trash_dest.parent / f"{trash_dest.stem}_{ts}{trash_dest.suffix}"
shutil.move(str(target), trash_dest)
Path(str(trash_dest) + ".trashinfo").write_text(json.dumps({
"original_path": str(rel),
"deleted_at": datetime.now().isoformat(timespec="seconds"),
}))
def _trash_restore(trash_rel: str, conflict: str = "") -> tuple:
target = (TRASH_ROOT / trash_rel).resolve()
if not str(target).startswith(str(TRASH_ROOT)):
return False, "forbidden"
if not target.is_file():
return False, "not_found"
info_path = Path(str(target) + ".trashinfo")
try:
original = json.loads(info_path.read_text())["original_path"]
except Exception:
original = trash_rel
dest = (ASSETS_ROOT / original).resolve()
if not str(dest).startswith(str(ASSETS_ROOT)):
return False, "forbidden"
if dest.exists():
if not conflict:
return False, "conflict"
if conflict == "rename":
dest = _auto_rename(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(target), str(dest))
if info_path.exists():
info_path.unlink()
return True, str(dest.relative_to(ASSETS_ROOT))
def _trash_purge(days: int = 30) -> int:
if not TRASH_ROOT.exists():
return 0
cutoff = datetime.now() - timedelta(days=days)
count = 0
for info_file in list(TRASH_ROOT.rglob("*.trashinfo")):
try:
deleted_at = datetime.fromisoformat(
json.loads(info_file.read_text())["deleted_at"]
)
if deleted_at < cutoff:
f = Path(str(info_file)[:-len(".trashinfo")])
if f.exists():
f.unlink()
info_file.unlink()
count += 1
except Exception:
pass
for d in sorted(TRASH_ROOT.rglob("*"), reverse=True):
if d.is_dir() and d != TRASH_ROOT:
try:
d.rmdir()
except OSError:
pass
return count
def _trash_list() -> list:
if not TRASH_ROOT.exists():
return []
entries = []
for info_file in TRASH_ROOT.rglob("*.trashinfo"):
try:
data = json.loads(info_file.read_text())
fpath = Path(str(info_file)[:-len(".trashinfo")])
if not fpath.exists():
info_file.unlink()
continue
rel = fpath.relative_to(TRASH_ROOT)
ext = fpath.suffix.lower()
stat = fpath.stat()
entries.append({
"name": fpath.name,
"path": str(rel),
"original_path": data.get("original_path", str(rel)),
"deleted_at": datetime.fromisoformat(data["deleted_at"]),
"size": stat.st_size,
"is_dir": False,
"is_image": ext in IMAGE_EXT,
"is_text": ext in TEXT_EXT,
"is_pdf": ext in PDF_EXT,
"ext": ext,
})
except Exception:
pass
entries.sort(key=lambda e: e["deleted_at"], reverse=True)
return entries
def _trash_count() -> int:
if not TRASH_ROOT.exists():
return 0
return sum(1 for f in TRASH_ROOT.rglob("*")
if f.is_file() and not f.name.endswith(".trashinfo"))
def _folder_stats(path: Path) -> dict: def _folder_stats(path: Path) -> dict:
files, size = 0, 0 files, size = 0, 0
for f in path.rglob("*"): for f in path.rglob("*"):
@ -226,6 +333,13 @@ def _entry(item: Path) -> dict:
} }
# ── Context processor ────────────────────────────────────────────────
@app.context_processor
def _inject_globals():
return {"trash_count": _trash_count() if _user() else 0}
# ── Auth ────────────────────────────────────────────────────────────── # ── Auth ──────────────────────────────────────────────────────────────
@app.route("/auth/login") @app.route("/auth/login")
@ -544,10 +658,83 @@ def delete_file():
if top in _HIDDEN or top.startswith("."): if top in _HIDDEN or top.startswith("."):
abort(403) abort(403)
parent = str(Path(subpath).parent) parent = str(Path(subpath).parent)
target.unlink() _trash_move(target, subpath)
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
# ── Corbeille ────────────────────────────────────────────────────────
@app.route("/trash")
def trash_list():
redir = _require_admin()
if redir:
return redir
_trash_purge(30)
entries = _trash_list()
return render_template("trash.html", entries=entries, humansize=_humansize)
@app.route("/trash/raw/<path:subpath>")
def trash_raw(subpath):
redir = _require_admin()
if redir:
return redir
target = (TRASH_ROOT / subpath).resolve()
if not str(target).startswith(str(TRASH_ROOT)):
abort(400)
if not target.is_file():
abort(404)
return send_from_directory(TRASH_ROOT, subpath)
@app.route("/trash/delete", methods=["POST"])
def trash_delete():
redir = _require_admin()
if redir:
return redir
trash_rel = request.form.get("path", "").strip()
if not trash_rel:
abort(400)
target = (TRASH_ROOT / trash_rel).resolve()
if not str(target).startswith(str(TRASH_ROOT)):
abort(400)
if target.is_file():
target.unlink()
info = Path(str(target) + ".trashinfo")
if info.exists():
info.unlink()
return redirect(url_for("trash_list"))
@app.route("/trash/restore", methods=["POST"])
def trash_restore():
redir = _require_admin()
if redir:
return redir
trash_rel = request.form.get("path", "").strip()
conflict = request.form.get("conflict", "").strip()
if not trash_rel:
abort(400)
ok, result = _trash_restore(trash_rel, conflict)
if not ok:
if result == "conflict":
return jsonify({"conflict": True, "path": trash_rel}), 409
abort(400)
parent = str(Path(result).parent)
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
@app.route("/trash/empty", methods=["POST"])
def trash_empty():
redir = _require_admin()
if redir:
return redir
if TRASH_ROOT.exists():
shutil.rmtree(TRASH_ROOT)
TRASH_ROOT.mkdir(exist_ok=True)
return redirect(url_for("trash_list"))
# ── Renommage de fichiers ───────────────────────────────────────────── # ── Renommage de fichiers ─────────────────────────────────────────────
@app.route("/rename", methods=["POST"]) @app.route("/rename", methods=["POST"])

View file

@ -29,6 +29,11 @@ header { background: var(--blue-dark); color: #fff; }
.header-nav a { color: rgba(255,255,255,.8); padding: .4rem .8rem; border-radius: 6px; font-size: .9rem; transition: background .15s; } .header-nav a { color: rgba(255,255,255,.8); padding: .4rem .8rem; border-radius: 6px; font-size: .9rem; transition: background .15s; }
.header-nav a:hover { background: rgba(255,255,255,.12); color: #fff; text-decoration: none; } .header-nav a:hover { background: rgba(255,255,255,.12); color: #fff; text-decoration: none; }
.header-nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; } .header-nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; }
.nav-trash { position: relative; }
.trash-badge { display: inline-flex; align-items: center; justify-content: center;
background: #ef4444; color: #fff; border-radius: 9px; font-size: .68rem;
font-weight: 700; min-width: 1.1rem; height: 1.1rem; padding: 0 .3rem;
margin-left: .3rem; vertical-align: middle; line-height: 1; }
.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; white-space: nowrap; } .header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; white-space: nowrap; }
.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; } .btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; }
@ -242,3 +247,16 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.col-date { display: none; } .col-date { display: none; }
.stat-box .value { font-size: 1.4rem; } .stat-box .value { font-size: 1.4rem; }
} }
/* ── Corbeille ──────────────────────────────────────────────────────── */
.trash-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .5rem; }
.trash-header h2 { margin: 0; font-size: 1.1rem; }
.trash-count-label { font-weight: 400; color: var(--muted); }
.trash-info { font-size: .82rem; color: var(--muted); font-style: italic; margin-bottom: 1rem; }
.trash-filename { font-weight: 500; }
.col-origin { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; color: var(--muted); }
.btn-restore { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .2rem; line-height: 1; transition: opacity .15s; }
.btn-restore:hover { opacity: 1; }
.btn-del--perm { color: #b91c1c; }
.btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; }
.btn-danger:hover { background: #991b1b; }

View file

@ -0,0 +1,124 @@
{#
Partial : tableau de fichiers.
Paramètres de contexte :
entries liste de dicts (champs _entry ou _trash_list)
mode "browse" | "trash"
has_hits booléen (browse uniquement)
humansize filtre taille
subpath chemin courant (browse uniquement, pour la ligne "..")
breadcrumb liste de crumbs (browse uniquement)
#}
<table class="file-table">
<thead>
<tr>
<th class="col-icon"></th>
<th>Nom</th>
{% if mode == 'trash' %}
<th class="col-origin">Emplacement d'origine</th>
<th class="col-date">Supprimé le</th>
<th class="col-size">Taille</th>
{% else %}
<th class="col-size">Taille</th>
<th class="col-date">Modifié le</th>
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
{% endif %}
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{% if mode == 'browse' and subpath %}
<tr>
<td class="col-icon"></td>
<td>
{% if breadcrumb | length > 1 %}
<a href="{{ url_for('browse', subpath=breadcrumb[-2].path) }}">.. (dossier parent)</a>
{% else %}
<a href="{{ url_for('browse') }}">.. (racine)</a>
{% endif %}
</td>
<td></td><td></td>
{% if has_hits %}<td></td>{% endif %}
<td></td>
</tr>
{% endif %}
{% for e in entries %}
<tr>
<td class="col-icon">
{%- if e.is_dir -%}📁
{%- elif e.is_image -%}🖼
{%- elif e.is_pdf -%}📕
{%- elif e.is_text -%}📄
{%- else -%}📎
{%- endif -%}
</td>
<td>
<div class="col-name">
{% if e.is_image %}
<img class="thumb-sm"
src="{{ url_for('trash_raw' if mode == 'trash' else 'raw_file', subpath=e.path) }}"
alt="{{ e.name }}" loading="lazy">
{% endif %}
{% if mode == 'trash' %}
<span class="trash-filename">{{ e.name }}</span>
{% else %}
<a href="{{ url_for('browse', subpath=e.path) }}">
{{ e.name }}{% if e.is_dir %}/{% endif %}
</a>
{% endif %}
{% if e.ext and not e.is_dir %}
<span class="type-badge">{{ e.ext }}</span>
{% endif %}
</div>
</td>
{% if mode == 'trash' %}
<td class="col-origin" title="{{ e.original_path }}">{{ e.original_path }}</td>
<td class="col-date">{{ e.deleted_at.strftime('%d/%m/%Y %H:%M') }}</td>
<td class="col-size">{{ humansize(e.size) }}</td>
{% else %}
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
{% if has_hits %}
<td class="col-hits">
{% if not e.is_dir %}
{% if e.hits %}
<span class="hits-badge hits-active" title="{{ e.hits }} requête(s) dans les stats">{{ e.hits }}</span>
{% else %}
<span class="hits-badge hits-zero" title="Aucune vue">0</span>
{% endif %}
{% endif %}
</td>
{% endif %}
{% endif %}
<td class="col-actions">
{% if mode == 'browse' and not e.is_dir %}
<div class="row-actions">
<button type="button" class="btn-rename"
data-path="{{ e.path }}" data-name="{{ e.name }}"
title="Renommer">✏️</button>
<form method="post" action="{{ url_for('delete_file') }}" style="display:contents">
<input type="hidden" name="path" value="{{ e.path }}">
<button type="submit" class="btn-del" title="Mettre à la corbeille">🗑</button>
</form>
</div>
{% elif mode == 'trash' %}
<div class="row-actions">
<button type="button" class="btn-restore"
data-path="{{ e.path }}" title="Restaurer">♻️</button>
<form method="post" action="{{ url_for('trash_delete') }}" style="display:contents">
<input type="hidden" name="path" value="{{ e.path }}">
<button type="submit" class="btn-del btn-del--perm"
title="Supprimer définitivement"
onclick="return confirm('Supprimer définitivement {{ e.name|replace("'","\\'")|replace('"','&quot;') }} ?')">🗑</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -22,6 +22,8 @@
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a> {% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
<a href="{{ url_for('stats') }}" <a href="{{ url_for('stats') }}"
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a> {% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
<a href="{{ url_for('trash_list') }}"
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
</nav> </nav>
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search"> <form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
<input type="search" name="q" placeholder="Rechercher…" <input type="search" name="q" placeholder="Rechercher…"

View file

@ -18,92 +18,8 @@
</nav> </nav>
{% if entries or subpath %} {% if entries or subpath %}
<table class="file-table"> {% set mode = 'browse' %}
<thead> {% include '_file_table.html' %}
<tr>
<th class="col-icon"></th>
<th>Nom</th>
<th class="col-size">Taille</th>
<th class="col-date">Modifié le</th>
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{% if subpath %}
<tr>
<td class="col-icon"></td>
<td>
{% if breadcrumb | length > 1 %}
<a href="{{ url_for('browse', subpath=breadcrumb[-2].path) }}">.. (dossier parent)</a>
{% else %}
<a href="{{ url_for('browse') }}">.. (racine)</a>
{% endif %}
</td>
<td></td><td></td>
{% if has_hits %}<td></td>{% endif %}
<td></td>
</tr>
{% endif %}
{% for e in entries %}
<tr>
<td class="col-icon">
{%- if e.is_dir -%}📁
{%- elif e.is_image -%}🖼
{%- elif e.is_pdf -%}📕
{%- elif e.is_text -%}📄
{%- else -%}📎
{%- endif -%}
</td>
<td>
<div class="col-name">
{% if e.is_image %}
<img class="thumb-sm"
src="{{ url_for('raw_file', subpath=e.path) }}"
alt="{{ e.name }}"
loading="lazy">
{% endif %}
<a href="{{ url_for('browse', subpath=e.path) }}">
{{ e.name }}{% if e.is_dir %}/{% endif %}
</a>
{% if e.ext and not e.is_dir %}
<span class="type-badge">{{ e.ext }}</span>
{% endif %}
</div>
</td>
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
{% if has_hits %}
<td class="col-hits">
{% if not e.is_dir %}
{% if e.hits %}
<span class="hits-badge hits-active" title="{{ e.hits }} requête(s) dans les stats">{{ e.hits }}</span>
{% else %}
<span class="hits-badge hits-zero" title="Aucune vue dans les stats">0</span>
{% endif %}
{% endif %}
</td>
{% endif %}
<td class="col-actions">
{% if not e.is_dir %}
<div class="row-actions">
<button type="button" class="btn-rename"
data-path="{{ e.path }}" data-name="{{ e.name }}"
title="Renommer">✏️</button>
<form method="post" action="{{ url_for('delete_file') }}" style="display:contents">
<input type="hidden" name="path" value="{{ e.path }}">
<button type="submit" class="btn-del" title="Supprimer du CDN">🗑</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %} {% else %}
<p class="empty">Dossier vide.</p> <p class="empty">Dossier vide.</p>
{% endif %} {% endif %}

92
app/templates/trash.html Normal file
View file

@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Corbeille{% endblock %}
{% block content %}
<section class="card">
<div class="trash-header">
<h2>Corbeille
{% if entries %}
<span class="trash-count-label">— {{ entries|length }} fichier{{ 's' if entries|length > 1 else '' }}</span>
{% endif %}
</h2>
{% if entries %}
<form method="post" action="{{ url_for('trash_empty') }}"
onsubmit="return confirm('Vider définitivement toute la corbeille ?')">
<button type="submit" class="btn btn-danger">Vider la corbeille</button>
</form>
{% endif %}
</div>
<p class="trash-info">Les fichiers sont supprimés définitivement après 30 jours.</p>
{% if entries %}
{% set mode = 'trash' %}
{% include '_file_table.html' %}
{% else %}
<p class="empty">La corbeille est vide.</p>
{% endif %}
</section>
<div id="restore-conflict-panel" class="conflict-panel" style="display:none">
<p class="conflict-title">⚠ Un fichier existe déjà à cet emplacement : <strong id="restore-conflict-name"></strong></p>
<div class="resize-chips resize-chips--radio">
<label class="chip"><input type="radio" name="restore-conflict" value="overwrite" checked><span>Écraser</span></label>
<label class="chip"><input type="radio" name="restore-conflict" value="rename"><span>Renommer</span></label>
</div>
<div class="conflict-actions">
<button type="button" id="restore-confirm" class="btn btn-primary">Confirmer la restauration</button>
<button type="button" id="restore-cancel" class="btn-rename-cancel">Annuler</button>
</div>
</div>
<script>
(function () {
const RESTORE_URL = {{ url_for('trash_restore') | tojson }};
const conflictPanel = document.getElementById('restore-conflict-panel');
const conflictName = document.getElementById('restore-conflict-name');
const confirmBtn = document.getElementById('restore-confirm');
const cancelBtn = document.getElementById('restore-cancel');
let pendingPath = null;
async function doRestore(path, conflict) {
const fd = new FormData();
fd.append('path', path);
if (conflict) fd.append('conflict', conflict);
try {
const resp = await fetch(RESTORE_URL, { method: 'POST', body: fd });
if (resp.status === 409) {
pendingPath = path;
conflictName.textContent = path.split('/').pop();
conflictPanel.style.display = 'block';
conflictPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
if (resp.redirected) { window.location.href = resp.url; return; }
if (resp.ok) { window.location.href = resp.url || window.location.href; return; }
alert('Erreur lors de la restauration.');
} catch (_) {
alert('Erreur réseau.');
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.btn-restore');
if (!btn) return;
doRestore(btn.dataset.path, '');
});
confirmBtn.addEventListener('click', () => {
const strategy = document.querySelector('input[name="restore-conflict"]:checked')?.value || 'overwrite';
conflictPanel.style.display = 'none';
if (pendingPath) doRestore(pendingPath, strategy);
pendingPath = null;
});
cancelBtn.addEventListener('click', () => {
conflictPanel.style.display = 'none';
pendingPath = null;
});
})();
</script>
{% endblock %}