From b3af420d3649acec3e3e6e83cce69da9cfbb5a27 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 10:40:35 +0200 Subject: [PATCH] feat: corbeille avec purge automatique 30 jours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/app.py | 191 ++++++++++++++++++++++++++++++++- app/static/app.css | 18 ++++ app/templates/_file_table.html | 124 +++++++++++++++++++++ app/templates/base.html | 2 + app/templates/browse.html | 88 +-------------- app/templates/trash.html | 92 ++++++++++++++++ 6 files changed, 427 insertions(+), 88 deletions(-) create mode 100644 app/templates/_file_table.html create mode 100644 app/templates/trash.html diff --git a/app/app.py b/app/app.py index cb0401c..209b636 100644 --- a/app/app.py +++ b/app/app.py @@ -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/") +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"]) diff --git a/app/static/app.css b/app/static/app.css index c09e010..8b6f078 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -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; } diff --git a/app/templates/_file_table.html b/app/templates/_file_table.html new file mode 100644 index 0000000..0484641 --- /dev/null +++ b/app/templates/_file_table.html @@ -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) +#} + + + + + + {% if mode == 'trash' %} + + + + {% else %} + + + {% if has_hits %}{% endif %} + {% endif %} + + + + + + {% if mode == 'browse' and subpath %} + + + + + {% if has_hits %}{% endif %} + + + {% endif %} + + {% for e in entries %} + + + + + {% if mode == 'trash' %} + + + + {% else %} + + + {% if has_hits %} + + {% endif %} + {% endif %} + + + + {% endfor %} + + +
NomEmplacement d'origineSupprimé leTailleTailleModifié leVues
+ {% if breadcrumb | length > 1 %} + .. (dossier parent) + {% else %} + .. (racine) + {% endif %} +
+ {%- if e.is_dir -%}📁 + {%- elif e.is_image -%}🖼 + {%- elif e.is_pdf -%}📕 + {%- elif e.is_text -%}📄 + {%- else -%}📎 + {%- endif -%} + +
+ {% if e.is_image %} + {{ e.name }} + {% endif %} + {% if mode == 'trash' %} + {{ e.name }} + {% else %} + + {{ e.name }}{% if e.is_dir %}/{% endif %} + + {% endif %} + {% if e.ext and not e.is_dir %} + {{ e.ext }} + {% endif %} +
+
{{ e.original_path }}{{ e.deleted_at.strftime('%d/%m/%Y %H:%M') }}{{ humansize(e.size) }}{{ humansize(e.size) if e.size is not none else '—' }}{{ e.mtime.strftime('%d/%m/%Y %H:%M') }} + {% if not e.is_dir %} + {% if e.hits %} + {{ e.hits }} + {% else %} + 0 + {% endif %} + {% endif %} + + {% if mode == 'browse' and not e.is_dir %} +
+ +
+ + +
+
+ {% elif mode == 'trash' %} +
+ +
+ + +
+
+ {% endif %} +
diff --git a/app/templates/base.html b/app/templates/base.html index 0a37b19..4fa2d47 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,6 +22,8 @@ {% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir Statistiques + Corbeille{% if trash_count %}{{ trash_count }}{% endif %}