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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 20:28:10 +02:00
parent 6515b3a256
commit 1a831bfb04
2 changed files with 47 additions and 6 deletions

View File

@ -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/<int:file_id>', methods=['PUT'])

View File

@ -78,14 +78,16 @@
<template #body="{ data }">
<div class="row-actions">
<Button
v-if="!data.is_folder"
icon="pi pi-download"
:icon="data.is_folder ? 'pi pi-file-zip' : 'pi pi-download'"
text rounded size="small"
:title="data.is_folder ? 'Als ZIP herunterladen' : 'Herunterladen'"
@click.stop="downloadFile(data)"
/>
<Button
icon="pi pi-share-alt"
:icon="(data.has_shares || data.has_permissions) ? 'pi pi-users' : 'pi pi-share-alt'"
text rounded size="small"
:severity="(data.has_shares || data.has_permissions) ? 'success' : undefined"
:title="(data.has_shares || data.has_permissions) ? 'Freigaben verwalten' : 'Teilen'"
@click.stop="openShare(data)"
/>
<Button