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 threading
from pathlib import Path
from datetime import datetime
from datetime import datetime, timedelta
from urllib.parse import urlencode
from PIL import Image
@ -33,6 +33,7 @@ oauth.register(
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())
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_LOGOUT_URL = _alpid_base + "/protocol/openid-connect/logout"
@ -172,6 +173,112 @@ def _auto_rename(p: Path) -> Path:
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:
files, size = 0, 0
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 ──────────────────────────────────────────────────────────────
@app.route("/auth/login")
@ -544,10 +658,83 @@ def delete_file():
if top in _HIDDEN or top.startswith("."):
abort(403)
parent = str(Path(subpath).parent)
target.unlink()
_trash_move(target, subpath)
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 ─────────────────────────────────────────────
@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: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; }
.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; }
.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; }
.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>
<a href="{{ url_for('stats') }}"
{% 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>
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
<input type="search" name="q" placeholder="Rechercher…"

View file

@ -18,92 +18,8 @@
</nav>
{% if entries or subpath %}
<table class="file-table">
<thead>
<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>
{% set mode = 'browse' %}
{% include '_file_table.html' %}
{% else %}
<p class="empty">Dossier vide.</p>
{% 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 %}