feat: Unterordner-Navigation in Ordner-Freigaben
Geteilte Ordner koennen jetzt komplett durchnavigiert werden: - Klick auf einen Unterordner oeffnet dessen Inhalt - Breadcrumb-Navigation: Root > Unterordner > Unter-Unterordner mit Klick zurueck auf jede Ebene - Ordner-Zeilen haben Hover-Effekt und Cursor-Pointer Backend: - GET /share/<token>/files?parent_id=X - Unterordner auflisten - Sicherheitspruefung: parent_id muss innerhalb des geteilten Ordners liegen (_is_inside_shared_folder traversiert den Baum) - Breadcrumb wird serverseitig berechnet - Download + Delete erlauben jetzt Dateien in jeder Tiefe (nicht nur direkte Kinder) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a26788ad2
commit
6515b3a256
|
|
@ -503,9 +503,21 @@ def share_info(token):
|
||||||
}), 200
|
}), 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/<token>/files', methods=['GET'])
|
@api_bp.route('/share/<token>/files', methods=['GET'])
|
||||||
def share_list_files(token):
|
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()
|
link = ShareLink.query.filter_by(token=token).first()
|
||||||
if not link:
|
if not link:
|
||||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||||
|
|
@ -516,27 +528,49 @@ def share_list_files(token):
|
||||||
if link.permission == 'upload_only':
|
if link.permission == 'upload_only':
|
||||||
return jsonify({'error': 'Dieser Link erlaubt keinen Einblick'}), 403
|
return jsonify({'error': 'Dieser Link erlaubt keinen Einblick'}), 403
|
||||||
|
|
||||||
# Check password via header
|
|
||||||
if link.password_hash:
|
if link.password_hash:
|
||||||
password = request.args.get('password', '') or request.headers.get('X-Share-Password', '')
|
password = request.args.get('password', '') or request.headers.get('X-Share-Password', '')
|
||||||
if not bcrypt.check_password_hash(link.password_hash, password):
|
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||||
return jsonify({'error': 'Passwort erforderlich'}), 401
|
return jsonify({'error': 'Passwort erforderlich'}), 401
|
||||||
|
|
||||||
f = db.session.get(File, link.file_id)
|
shared_folder = db.session.get(File, link.file_id)
|
||||||
if not f.is_folder:
|
if not shared_folder.is_folder:
|
||||||
return jsonify({'error': 'Kein Ordner'}), 400
|
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()
|
.order_by(File.is_folder.desc(), File.name).all()
|
||||||
|
|
||||||
return jsonify([{
|
# 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,
|
'id': fi.id,
|
||||||
'name': fi.name,
|
'name': fi.name,
|
||||||
'is_folder': fi.is_folder,
|
'is_folder': fi.is_folder,
|
||||||
'size': fi.size,
|
'size': fi.size,
|
||||||
'mime_type': fi.mime_type,
|
'mime_type': fi.mime_type,
|
||||||
'updated_at': fi.updated_at.isoformat() if fi.updated_at else None,
|
'updated_at': fi.updated_at.isoformat() if fi.updated_at else None,
|
||||||
} for fi in files]), 200
|
} for fi in files],
|
||||||
|
'breadcrumb': breadcrumb,
|
||||||
|
'current_parent_id': parent_id,
|
||||||
|
'shared_root_id': shared_folder.id,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/share/<token>/files/<int:file_id>/download', methods=['GET'])
|
@api_bp.route('/share/<token>/files/<int:file_id>/download', methods=['GET'])
|
||||||
|
|
@ -557,14 +591,12 @@ def share_download_file(token, file_id):
|
||||||
if not bcrypt.check_password_hash(link.password_hash, password):
|
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||||
return jsonify({'error': 'Passwort erforderlich'}), 401
|
return jsonify({'error': 'Passwort erforderlich'}), 401
|
||||||
|
|
||||||
# Verify file belongs to the shared folder
|
|
||||||
target_file = db.session.get(File, file_id)
|
target_file = db.session.get(File, file_id)
|
||||||
if not target_file:
|
if not target_file:
|
||||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||||
|
|
||||||
# Check file is inside shared folder (direct child)
|
# Check file is inside shared folder (any depth)
|
||||||
shared_folder = db.session.get(File, link.file_id)
|
if not _is_inside_shared_folder(target_file, link.file_id):
|
||||||
if target_file.parent_id != shared_folder.id:
|
|
||||||
return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403
|
return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403
|
||||||
|
|
||||||
if target_file.is_folder:
|
if target_file.is_folder:
|
||||||
|
|
@ -603,8 +635,7 @@ def share_delete_file(token, file_id):
|
||||||
if not target_file:
|
if not target_file:
|
||||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||||
|
|
||||||
shared_folder = db.session.get(File, link.file_id)
|
if not _is_inside_shared_folder(target_file, link.file_id):
|
||||||
if target_file.parent_id != shared_folder.id:
|
|
||||||
return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403
|
return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403
|
||||||
|
|
||||||
# Delete from disk
|
# Delete from disk
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,16 @@
|
||||||
|
|
||||||
<!-- Folder: file listing (read + write) -->
|
<!-- Folder: file listing (read + write) -->
|
||||||
<div v-if="fileInfo.is_folder && fileInfo.permission !== 'upload_only'" class="folder-content">
|
<div v-if="fileInfo.is_folder && fileInfo.permission !== 'upload_only'" class="folder-content">
|
||||||
|
<div class="folder-breadcrumb">
|
||||||
|
<a class="crumb" @click="navigateToRoot">{{ fileInfo.name }}</a>
|
||||||
|
<template v-for="item in folderBreadcrumb" :key="item.id">
|
||||||
|
<i class="pi pi-angle-right crumb-sep"></i>
|
||||||
|
<a class="crumb" @click="navigateToFolder(item.id)">{{ item.name }}</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="folder-toolbar">
|
<div class="folder-toolbar">
|
||||||
<span class="folder-count">{{ folderFiles.length }} Dateien</span>
|
<span class="folder-count">{{ folderFiles.length }} Eintraege</span>
|
||||||
<Button v-if="fileInfo.upload_allowed" icon="pi pi-upload" label="Hochladen" size="small" @click="$refs.uploadInput.click()" />
|
<Button v-if="fileInfo.upload_allowed" icon="pi pi-upload" label="Hochladen" size="small" @click="$refs.uploadInput.click()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -48,7 +56,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="file-list">
|
<div v-else class="file-list">
|
||||||
<div v-for="f in folderFiles" :key="f.id" class="file-item">
|
<div v-for="f in folderFiles" :key="f.id" class="file-item"
|
||||||
|
:class="{ clickable: f.is_folder }" @click="f.is_folder && navigateToFolder(f.id)">
|
||||||
<div class="file-info">
|
<div class="file-info">
|
||||||
<i :class="f.is_folder ? 'pi pi-folder' : 'pi pi-file'"></i>
|
<i :class="f.is_folder ? 'pi pi-folder' : 'pi pi-file'"></i>
|
||||||
<div class="file-details">
|
<div class="file-details">
|
||||||
|
|
@ -58,9 +67,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<Button v-if="!f.is_folder" icon="pi pi-download" text size="small"
|
<Button v-if="!f.is_folder" icon="pi pi-download" text size="small"
|
||||||
@click="downloadFolderFile(f)" />
|
@click.stop="downloadFolderFile(f)" />
|
||||||
<Button v-if="fileInfo.permission === 'write'" icon="pi pi-trash" text size="small"
|
<Button v-if="fileInfo.permission === 'write'" icon="pi pi-trash" text size="small"
|
||||||
severity="danger" @click="deleteFolderFile(f)" />
|
severity="danger" @click.stop="deleteFolderFile(f)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!folderFiles.length" class="empty-folder">
|
<div v-if="!folderFiles.length" class="empty-folder">
|
||||||
|
|
@ -122,6 +131,8 @@ const authError = ref('')
|
||||||
const verifying = ref(false)
|
const verifying = ref(false)
|
||||||
|
|
||||||
const folderFiles = ref([])
|
const folderFiles = ref([])
|
||||||
|
const folderBreadcrumb = ref([])
|
||||||
|
const currentParentId = ref(null)
|
||||||
const loadingFiles = ref(false)
|
const loadingFiles = ref(false)
|
||||||
|
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
@ -179,18 +190,31 @@ async function verifyPassword() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFolderFiles() {
|
async function loadFolderFiles(parentId = null) {
|
||||||
loadingFiles.value = true
|
loadingFiles.value = true
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`/api/share/${token}/files`, { headers: getAuthHeaders() })
|
const params = {}
|
||||||
folderFiles.value = res.data
|
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 {
|
} catch {
|
||||||
folderFiles.value = []
|
folderFiles.value = []
|
||||||
|
folderBreadcrumb.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loadingFiles.value = false
|
loadingFiles.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateToFolder(folderId) {
|
||||||
|
loadFolderFiles(folderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToRoot() {
|
||||||
|
loadFolderFiles(null)
|
||||||
|
}
|
||||||
|
|
||||||
function downloadFile() {
|
function downloadFile() {
|
||||||
let url = `/api/share/${token}/download`
|
let url = `/api/share/${token}/download`
|
||||||
if (fileInfo.value?.has_password && password.value) {
|
if (fileInfo.value?.has_password && password.value) {
|
||||||
|
|
@ -287,6 +311,13 @@ onMounted(loadInfo)
|
||||||
|
|
||||||
/* Folder content */
|
/* Folder content */
|
||||||
.folder-content { margin-bottom: 1.5rem; }
|
.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 {
|
.folder-toolbar {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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);
|
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;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
|
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 { 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-info i { font-size: 1.1rem; color: var(--p-surface-500); flex-shrink: 0; }
|
||||||
.file-details { display: flex; flex-direction: column; min-width: 0; }
|
.file-details { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue