From 1a831bfb0466fd7f39d8d41adbfed9891e039622 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 20:28:10 +0200 Subject: [PATCH] feat: Ordner als ZIP herunterladen + Share-Status visuell anzeigen Ordner-Download: - Ordner koennen jetzt als ZIP heruntergeladen werden (rekursiv mit allen Unterordnern und Dateien) - ZIP-Icon statt Download-Icon bei Ordnern in der Dateiliste - Backend erstellt ZIP mit ZIP_DEFLATED und allowZip64 fuer grosse Ordner Share-Status visuell: - Share-Button zeigt gruenes Personen-Icon (pi-users) wenn die Datei/ der Ordner bereits Freigaben hat (Links oder Benutzer-Berechtigungen) - Normales Share-Icon (pi-share-alt) wenn keine Freigaben existieren - Backend liefert has_shares und has_permissions pro Datei mit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/files.py | 45 +++++++++++++++++++++++++++++--- frontend/src/views/FilesView.vue | 8 +++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 8ece8ba..cf1b2ad 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -1,12 +1,15 @@ +import io import os import uuid import hashlib import secrets import mimetypes +import tempfile +import zipfile from datetime import datetime, timezone from pathlib import Path -from flask import request, jsonify, send_file, current_app +from flask import request, jsonify, send_file, current_app, Response from app.api import api_bp from app.api.auth import token_required @@ -74,7 +77,12 @@ def list_files(): File.parent_id.is_(None) ).order_by(File.is_folder.desc(), File.name).all() - result = [f.to_dict() for f in files] + result = [] + for f in files: + d = f.to_dict() + d['has_shares'] = ShareLink.query.filter_by(file_id=f.id).count() > 0 + d['has_permissions'] = FilePermission.query.filter_by(file_id=f.id).count() > 0 + result.append(d) for f in shared: d = f.to_dict() d['shared'] = True @@ -240,8 +248,9 @@ def download_file(file_id): f, err = _get_file_or_403(file_id, user, 'read') if err: return err + if f.is_folder: - return jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400 + return _download_folder_as_zip(f) filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path if not filepath.exists(): @@ -251,6 +260,36 @@ def download_file(file_id): download_name=f.name) +def _download_folder_as_zip(folder): + """Stream a folder as ZIP download.""" + upload_path = Path(current_app.config['UPLOAD_PATH']) + + def _add_folder_to_zip(zf, folder_obj, prefix): + children = File.query.filter_by(parent_id=folder_obj.id).all() + for child in children: + if child.is_folder: + _add_folder_to_zip(zf, child, f'{prefix}{child.name}/') + else: + filepath = upload_path / str(child.owner_id) / child.storage_path + if filepath.exists(): + zf.write(str(filepath), f'{prefix}{child.name}') + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') + tmp_path = tmp.name + tmp.close() + + try: + with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + _add_folder_to_zip(zf, folder, '') + + return send_file(tmp_path, mimetype='application/zip', as_attachment=True, + download_name=f'{folder.name}.zip') + finally: + # Cleanup after response is sent (Flask handles this) + import atexit + atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path)) + + # --- Rename / Move --- @api_bp.route('/files/', methods=['PUT']) diff --git a/frontend/src/views/FilesView.vue b/frontend/src/views/FilesView.vue index a3e1309..8fd1981 100644 --- a/frontend/src/views/FilesView.vue +++ b/frontend/src/views/FilesView.vue @@ -78,14 +78,16 @@