diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index 781dcb0..65087b4 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.1.3 +1.1.4 diff --git a/srv/tesm/app.py b/srv/tesm/app.py index f035d7e..4b11872 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes, serialization from cryptography import x509 from datetime import datetime, timedelta -import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback +import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile import paramiko import yaml import ssl @@ -2340,6 +2340,21 @@ def fileshare_mkdir(): return redirect(url_for("fileshare", share=share, path=rel_path)) +def _fileshare_resolve_child(share, rel_path, name): + """Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und + stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt + (per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften + _fileshare_resolve_path) -- von delete/delete-multi/download-multi + gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie.""" + if not name: + return None + parent_abs = _fileshare_resolve_path(share, rel_path) + target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) + if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep): + return None + return target_abs + + @app.route("/fileshare/delete", methods=["POST"]) @login_required def fileshare_delete(): @@ -2350,9 +2365,8 @@ def fileshare_delete(): return redirect(url_for("fileshare", share=share, path=rel_path)) name = request.form.get("name", "") - parent_abs = _fileshare_resolve_path(share, rel_path) - target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None - if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep): + target_abs = _fileshare_resolve_child(share, rel_path, name) + if not target_abs: flash("Ungültiges Ziel.", "danger") else: try: @@ -2367,6 +2381,99 @@ def fileshare_delete(): return redirect(url_for("fileshare", share=share, path=rel_path)) +@app.route("/fileshare/delete-multi", methods=["POST"]) +@login_required +def fileshare_delete_multi(): + """Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge + an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle).""" + share = request.form.get("share", "") + rel_path = request.form.get("path", "") + if not current_user.has_permission("fileshare.edit"): + flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger") + return redirect(url_for("fileshare", share=share, path=rel_path)) + + names = [n for n in request.form.getlist("names") if n] + if not names: + flash("Keine Elemente ausgewählt.", "danger") + return redirect(url_for("fileshare", share=share, path=rel_path)) + + deleted, failed = [], [] + for name in names: + target_abs = _fileshare_resolve_child(share, rel_path, name) + if not target_abs: + failed.append(name) + continue + try: + if os.path.isdir(target_abs): + shutil.rmtree(target_abs) + else: + os.remove(target_abs) + deleted.append(name) + except OSError: + failed.append(name) + + if deleted: + log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})") + flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success") + if failed: + flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger") + return redirect(url_for("fileshare", share=share, path=rel_path)) + + +@app.route("/fileshare/download-multi", methods=["POST"]) +@login_required +def fileshare_download_multi(): + """Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen + -- vermeidet, dass der Browser bei vielen einzelnen Downloads auf + einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud + etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit + aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad).""" + share = request.form.get("share", "") + rel_path = request.form.get("path", "") + if not current_user.has_permission("fileshare.view"): + return "Keine Berechtigung.", 403 + + names = [n for n in request.form.getlist("names") if n] + if not names: + flash("Keine Elemente ausgewählt.", "danger") + return redirect(url_for("fileshare", share=share, path=rel_path)) + + targets = [] + skipped = [] + for name in names: + target_abs = _fileshare_resolve_child(share, rel_path, name) + if not target_abs or not os.path.exists(target_abs): + skipped.append(name) + continue + targets.append((name, target_abs)) + if not targets: + flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger") + return redirect(url_for("fileshare", share=share, path=rel_path)) + if skipped: + flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger") + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for name, target_abs in targets: + if os.path.isdir(target_abs): + for root, _dirs, files in os.walk(target_abs): + for fname in files: + full = os.path.join(root, fname) + arcname = os.path.join(name, os.path.relpath(full, target_abs)) + zf.write(full, arcname) + else: + zf.write(target_abs, name) + buffer.seek(0) + + if len(targets) == 1: + base_name, _ext = os.path.splitext(targets[0][0]) + zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download" + else: + zip_name = secure_filename(f"{share}-Auswahl") or "Download" + log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}") + return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip") + + @app.route("/fileshare/rename", methods=["POST"]) @login_required def fileshare_rename(): diff --git a/srv/tesm/templates/fileshare.html b/srv/tesm/templates/fileshare.html index c8d6962..947bf86 100644 --- a/srv/tesm/templates/fileshare.html +++ b/srv/tesm/templates/fileshare.html @@ -66,10 +66,25 @@ + + {% if entries %}
+ @@ -78,6 +93,7 @@ {% for e in entries %} +
Name Größe Geändert
{% if e.is_dir %} @@ -228,6 +244,8 @@