From 6a1774855231b04b54f5888de601bec2a7224e3a Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 18:35:10 +0200 Subject: [PATCH] feat: Upload in freigegebene Ordner + Benachrichtigung Share-Links fuer Ordner erlauben jetzt auch Uploads: Backend: - POST /share//upload - Datei in freigegebenen Ordner hochladen - Passwort-Schutz wird bei Upload ebenfalls geprueft - share_info gibt jetzt upload_allowed zurueck (true bei Ordner-Shares) - Email-Benachrichtigung an den Ersteller wenn jemand eine Datei hochlaedt (Dateiname, Groesse, IP-Adresse) Frontend (ShareView): - Ordner-Shares zeigen jetzt eine Upload-Zone (Drag & Drop + Button) - Fortschrittsbalken beim Upload mit Datei-Zaehler - Erfolgs-/Fehlermeldung nach Upload - Passwortgeschuetzte Ordner-Shares: erst entsperren, dann uploaden Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/files.py | 62 ++++++++++++++ backend/app/services/system_mail.py | 29 +++++++ frontend/src/views/ShareView.vue | 120 +++++++++++++++++++++++++--- 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 50e5cbd..b7c8768 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -490,6 +490,7 @@ def share_info(token): 'size': f.size, 'mime_type': f.mime_type, 'has_password': bool(link.password_hash), + 'upload_allowed': f.is_folder, }), 200 @@ -557,6 +558,67 @@ def share_download(token): download_name=f.name) +@api_bp.route('/share//upload', methods=['POST']) +def share_upload(token): + """Upload a file via a share link (only if the shared item is a folder).""" + link = ShareLink.query.filter_by(token=token).first() + if not link: + return jsonify({'error': 'Link nicht gefunden'}), 404 + + if link.is_expired(): + return jsonify({'error': 'Link abgelaufen'}), 410 + + # Check password if set + if link.password_hash: + password = request.form.get('password', '') or request.headers.get('X-Share-Password', '') + if not bcrypt.check_password_hash(link.password_hash, password): + return jsonify({'error': 'Passwort erforderlich'}), 401 + + f = db.session.get(File, link.file_id) + if not f.is_folder: + return jsonify({'error': 'Upload nur in freigegebene Ordner moeglich'}), 400 + + if 'file' not in request.files: + return jsonify({'error': 'Keine Datei gesendet'}), 400 + + uploaded = request.files['file'] + if not uploaded.filename: + return jsonify({'error': 'Leerer Dateiname'}), 400 + + filename = uploaded.filename + mime = uploaded.content_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + storage_name = str(uuid.uuid4()) + user_dir = _user_upload_dir(f.owner_id) + storage_path = user_dir / storage_name + uploaded.save(str(storage_path)) + + size = os.path.getsize(str(storage_path)) + checksum = _compute_checksum(str(storage_path)) + + file_obj = File( + owner_id=f.owner_id, + parent_id=f.id, + name=filename, + is_folder=False, + mime_type=mime, + size=size, + storage_path=storage_name, + checksum=checksum, + ) + db.session.add(file_obj) + db.session.commit() + + # Notify share link creator about the upload + try: + from app.services.system_mail import notify_share_link_upload + notify_share_link_upload(link, f.name, filename, size, request.remote_addr) + except Exception: + pass + + return jsonify(file_obj.to_dict()), 201 + + @api_bp.route('/share/', methods=['DELETE']) @token_required def delete_share_link(token): diff --git a/backend/app/services/system_mail.py b/backend/app/services/system_mail.py index dfbe1a7..d439007 100644 --- a/backend/app/services/system_mail.py +++ b/backend/app/services/system_mail.py @@ -70,6 +70,35 @@ def notify_share_link_accessed(share_link, file_name, accessor_ip): send_system_email(creator.email, subject, body) +def notify_share_link_upload(share_link, folder_name, filename, filesize, uploader_ip): + """Notify the share link creator that someone uploaded a file via their shared folder.""" + from app.models.user import User + + creator = User.query.get(share_link.created_by) + if not creator or not creator.email: + return + + def _fmt_size(b): + for u in ['B', 'KB', 'MB', 'GB']: + if b < 1024: + return f'{b:.1f} {u}' + b /= 1024 + return f'{b:.1f} TB' + + subject = f'Mini-Cloud: Neue Datei in geteiltem Ordner "{folder_name}"' + body = ( + f'Hallo {creator.username},\n\n' + f'Jemand hat eine Datei in deinen geteilten Ordner hochgeladen:\n\n' + f' Ordner: {folder_name}\n' + f' Datei: {filename}\n' + f' Groesse: {_fmt_size(filesize)}\n' + f' IP-Adresse: {uploader_ip}\n\n' + f'Melde dich in deiner Mini-Cloud an, um die Datei zu sehen.\n\n' + f'Deine Mini-Cloud' + ) + send_system_email(creator.email, subject, body) + + def notify_file_shared_with_user(file_name, owner_username, target_user): """Notify a user that a file/folder was shared with them.""" if not target_user.email: diff --git a/frontend/src/views/ShareView.vue b/frontend/src/views/ShareView.vue index a3ac4ac..da6e984 100644 --- a/frontend/src/views/ShareView.vue +++ b/frontend/src/views/ShareView.vue @@ -15,10 +15,11 @@ @@ -46,6 +71,8 @@ import axios from 'axios' import Button from 'primevue/button' import Password from 'primevue/password' import Message from 'primevue/message' +import Tag from 'primevue/tag' +import ProgressBar from 'primevue/progressbar' const route = useRoute() const token = route.params.token @@ -58,6 +85,12 @@ const authenticated = ref(false) const authError = ref('') const verifying = ref(false) +const isDragging = ref(false) +const uploading = ref(false) +const uploadPercent = ref(0) +const uploadStatus = ref('') +const uploadSuccess = ref('') + function formatSize(bytes) { if (!bytes) return '' const units = ['B', 'KB', 'MB', 'GB'] @@ -100,6 +133,54 @@ function downloadFile() { window.location.href = url } +async function onDrop(e) { + isDragging.value = false + const files = Array.from(e.dataTransfer.files) + if (files.length) await uploadFiles(files) +} + +async function handleUpload(event) { + const files = Array.from(event.target.files) + if (files.length) await uploadFiles(files) + event.target.value = '' +} + +async function uploadFiles(files) { + uploading.value = true + uploadSuccess.value = '' + uploadPercent.value = 0 + let uploaded = 0 + let errors = 0 + + for (const file of files) { + uploadStatus.value = `${uploaded + 1} / ${files.length}: ${file.name}` + try { + const formData = new FormData() + formData.append('file', file) + if (fileInfo.value?.has_password && password.value) { + formData.append('password', password.value) + } + await axios.post(`/api/share/${token}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + ...(password.value ? { 'X-Share-Password': password.value } : {}), + }, + }) + uploaded++ + } catch { + errors++ + } + uploadPercent.value = Math.round(((uploaded + errors) / files.length) * 100) + } + + uploading.value = false + if (errors) { + uploadSuccess.value = `${uploaded} hochgeladen, ${errors} fehlgeschlagen` + } else { + uploadSuccess.value = `${uploaded} Datei${uploaded !== 1 ? 'en' : ''} erfolgreich hochgeladen` + } +} + onMounted(loadInfo) @@ -110,14 +191,29 @@ onMounted(loadInfo) } .share-card { background: var(--p-surface-0); border-radius: 12px; padding: 3rem; - text-align: center; max-width: 450px; width: 100%; + text-align: center; max-width: 500px; width: 100%; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } .share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; } -.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; } +.file-size { color: var(--p-text-muted-color); margin-bottom: 1rem; } .password-form { text-align: left; margin-top: 1.5rem; } .password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); } .field { margin-bottom: 1rem; } -.download-section { margin-top: 1.5rem; } +.actions-section { margin-top: 1.5rem; } +.action-block { margin-bottom: 1.5rem; } .share-loading, .share-error { margin-top: 1.5rem; } + +.upload-area { + border: 2px dashed var(--p-surface-300); border-radius: 10px; + padding: 2rem; text-align: center; cursor: pointer; + transition: all 0.2s; +} +.upload-area:hover, .upload-area.dragging { + border-color: var(--p-primary-color); background: var(--p-primary-50); +} +.upload-area i { font-size: 2rem; color: var(--p-surface-400); } +.upload-area.dragging i { color: var(--p-primary-color); } +.upload-area p { margin: 0.5rem 0; color: var(--p-text-muted-color); font-size: 0.9rem; } +.upload-progress { margin-top: 1rem; } +.upload-progress p { font-size: 0.85rem; color: var(--p-text-muted-color); margin-top: 0.5rem; }