diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 5e2c463..8ece8ba 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -503,9 +503,21 @@ def share_info(token): }), 200 +def _is_inside_shared_folder(file_obj, shared_folder_id): + """Check if a file/folder is a descendant of the shared folder.""" + current = file_obj + while current: + if current.id == shared_folder_id: + return True + if current.parent_id is None: + return False + current = current.parent + return False + + @api_bp.route('/share//files', methods=['GET']) def share_list_files(token): - """List files in a shared folder (read or write permission required).""" + """List files in a shared folder or subfolder.""" link = ShareLink.query.filter_by(token=token).first() if not link: return jsonify({'error': 'Link nicht gefunden'}), 404 @@ -516,27 +528,49 @@ def share_list_files(token): if link.permission == 'upload_only': return jsonify({'error': 'Dieser Link erlaubt keinen Einblick'}), 403 - # Check password via header if link.password_hash: password = request.args.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: + shared_folder = db.session.get(File, link.file_id) + if not shared_folder.is_folder: return jsonify({'error': 'Kein Ordner'}), 400 - files = File.query.filter_by(parent_id=f.id)\ + # Allow browsing subfolders via parent_id parameter + parent_id = request.args.get('parent_id', None, type=int) + if parent_id is None: + parent_id = shared_folder.id + else: + # Verify the requested parent is inside the shared folder + target_parent = db.session.get(File, parent_id) + if not target_parent or not _is_inside_shared_folder(target_parent, shared_folder.id): + return jsonify({'error': 'Zugriff verweigert'}), 403 + + files = File.query.filter_by(parent_id=parent_id)\ .order_by(File.is_folder.desc(), File.name).all() - return jsonify([{ - 'id': fi.id, - 'name': fi.name, - 'is_folder': fi.is_folder, - 'size': fi.size, - 'mime_type': fi.mime_type, - 'updated_at': fi.updated_at.isoformat() if fi.updated_at else None, - } for fi in files]), 200 + # Build breadcrumb from current parent back to shared root + breadcrumb = [] + if parent_id != shared_folder.id: + current = db.session.get(File, parent_id) + while current and current.id != shared_folder.id: + breadcrumb.insert(0, {'id': current.id, 'name': current.name}) + current = current.parent + + return jsonify({ + 'files': [{ + 'id': fi.id, + 'name': fi.name, + 'is_folder': fi.is_folder, + 'size': fi.size, + 'mime_type': fi.mime_type, + 'updated_at': fi.updated_at.isoformat() if fi.updated_at else None, + } for fi in files], + 'breadcrumb': breadcrumb, + 'current_parent_id': parent_id, + 'shared_root_id': shared_folder.id, + }), 200 @api_bp.route('/share//files//download', methods=['GET']) @@ -557,14 +591,12 @@ def share_download_file(token, file_id): if not bcrypt.check_password_hash(link.password_hash, password): return jsonify({'error': 'Passwort erforderlich'}), 401 - # Verify file belongs to the shared folder target_file = db.session.get(File, file_id) if not target_file: return jsonify({'error': 'Datei nicht gefunden'}), 404 - # Check file is inside shared folder (direct child) - shared_folder = db.session.get(File, link.file_id) - if target_file.parent_id != shared_folder.id: + # Check file is inside shared folder (any depth) + if not _is_inside_shared_folder(target_file, link.file_id): return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403 if target_file.is_folder: @@ -603,8 +635,7 @@ def share_delete_file(token, file_id): if not target_file: return jsonify({'error': 'Datei nicht gefunden'}), 404 - shared_folder = db.session.get(File, link.file_id) - if target_file.parent_id != shared_folder.id: + if not _is_inside_shared_folder(target_file, link.file_id): return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403 # Delete from disk diff --git a/frontend/src/views/ShareView.vue b/frontend/src/views/ShareView.vue index 95c4ec4..00005fe 100644 --- a/frontend/src/views/ShareView.vue +++ b/frontend/src/views/ShareView.vue @@ -38,8 +38,16 @@
+ +
- {{ folderFiles.length }} Dateien + {{ folderFiles.length }} Eintraege
@@ -48,7 +56,8 @@
-
+
@@ -58,9 +67,9 @@
@@ -122,6 +131,8 @@ const authError = ref('') const verifying = ref(false) const folderFiles = ref([]) +const folderBreadcrumb = ref([]) +const currentParentId = ref(null) const loadingFiles = ref(false) const isDragging = ref(false) @@ -179,18 +190,31 @@ async function verifyPassword() { } } -async function loadFolderFiles() { +async function loadFolderFiles(parentId = null) { loadingFiles.value = true try { - const res = await axios.get(`/api/share/${token}/files`, { headers: getAuthHeaders() }) - folderFiles.value = res.data + const params = {} + if (parentId) params.parent_id = parentId + const res = await axios.get(`/api/share/${token}/files`, { params, headers: getAuthHeaders() }) + folderFiles.value = res.data.files + folderBreadcrumb.value = res.data.breadcrumb || [] + currentParentId.value = res.data.current_parent_id } catch { folderFiles.value = [] + folderBreadcrumb.value = [] } finally { loadingFiles.value = false } } +function navigateToFolder(folderId) { + loadFolderFiles(folderId) +} + +function navigateToRoot() { + loadFolderFiles(null) +} + function downloadFile() { let url = `/api/share/${token}/download` if (fileInfo.value?.has_password && password.value) { @@ -287,6 +311,13 @@ onMounted(loadInfo) /* Folder content */ .folder-content { margin-bottom: 1.5rem; } +.folder-breadcrumb { + display: flex; align-items: center; gap: 0.25rem; margin-bottom: 0.75rem; + font-size: 0.9rem; +} +.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; } +.crumb:hover { text-decoration: underline; } +.crumb-sep { font-size: 0.7rem; color: var(--p-surface-400); } .folder-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--p-surface-200); @@ -297,6 +328,8 @@ onMounted(loadInfo) display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100); } +.file-item.clickable { cursor: pointer; } +.file-item.clickable:hover { background: var(--p-surface-50); } .file-info { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; } .file-info i { font-size: 1.1rem; color: var(--p-surface-500); flex-shrink: 0; } .file-details { display: flex; flex-direction: column; min-width: 0; }