From 9369c851a09ffcc0a0998841d86b6ad97d67b34d Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sun, 12 Apr 2026 11:54:36 +0200 Subject: [PATCH] feat: Benutzerfreigabe - Weiterteilen-Recht + Lesezugriff wird erzwungen Neues Berechtigungs-Modell fuer Benutzerfreigaben: * FilePermission bekommt zwei neue Spalten: - can_reshare (bool): darf dieser Nutzer die Freigabe weiterverteilen? - granted_by (user_id): wer hat diese Freigabe erstellt? * set_permission / create_share_link erlauben jetzt auch Nicht-Owner, sofern sie can_reshare haben. Dabei gilt: - Lesend + reshare -> kann nur lesend weiterteilen - Schreibend + reshare -> kann lesend ODER schreibend weiterteilen - Admin kann nur der Eigentuemer vergeben - Jeder Re-Sharer kann wiederum can_reshare weitergeben * remove_permission: Owner kann alle Freigaben entfernen; Re-Sharer nur die von ihnen selbst erstellten. * get_permissions: Owner sieht alle; Re-Sharer nur selbst-erstellte. * list_files liefert my_permission + my_can_reshare pro Eintrag - Frontend kann Rename/Delete/Share-Buttons gezielt ein- und ausblenden statt blind alle anzuzeigen. Frontend: * Rename/Delete-Buttons nur fuer Write-Zugriff * Share-Button nur fuer Owner oder Re-Sharer * "darf weiterteilen" Checkbox neben Permission-Dropdown im Dialog * Dropdown-Optionen nach eigenem Level gefiltert (Re-Sharer sieht keine hoeheren Stufen als seine eigene) * Hinweis-Text "Du hast X - du kannst maximal X weiterteilen" Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/files.py | 118 +++++++++++++++++++++++++++---- backend/app/models/file.py | 5 +- frontend/src/views/FilesView.vue | 60 ++++++++++++++-- 3 files changed, 165 insertions(+), 18 deletions(-) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 8bd3416..979b2ba 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -33,6 +33,29 @@ def _share_recipients(file_obj): return list(ids) +def _effective_permission(file_obj, user): + """Returns (permission_level, can_reshare) for the given user on this file, + walking up the folder tree. Owner gets ('admin', True). Returns + (None, False) if no access.""" + if file_obj.owner_id == user.id: + return ('admin', True) + levels = {'read': 0, 'write': 1, 'admin': 2} + best_level = -1 + best_perm = None + best_reshare = False + cur = file_obj + while cur is not None: + perm = FilePermission.query.filter_by(file_id=cur.id, user_id=user.id).first() + if perm: + lvl = levels.get(perm.permission, -1) + if lvl > best_level: + best_level = lvl + best_perm = perm.permission + best_reshare = perm.can_reshare + cur = cur.parent + return (best_perm, best_reshare) + + def _user_upload_dir(user_id): base = Path(current_app.config['UPLOAD_PATH']) user_dir = base / str(user_id) @@ -120,6 +143,9 @@ def list_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 + my_perm, my_reshare = _effective_permission(f, user) + d['my_permission'] = my_perm + d['my_can_reshare'] = bool(my_reshare) lock = FileLock.get_lock(f.id) if lock: d['locked'] = True @@ -129,6 +155,9 @@ def list_files(): for f in shared: d = f.to_dict() d['shared'] = True + my_perm, my_reshare = _effective_permission(f, user) + d['my_permission'] = my_perm + d['my_can_reshare'] = bool(my_reshare) result.append(d) # Build breadcrumb @@ -543,12 +572,21 @@ def empty_trash(): @token_required def get_permissions(file_id): user = request.current_user - f, err = _get_file_or_403(file_id, user, 'admin') - if err: - if not (f := db.session.get(File, file_id)) or f.owner_id != user.id: - return jsonify({'error': 'Zugriff verweigert'}), 403 + f = db.session.get(File, file_id) + if not f: + return jsonify({'error': 'Datei nicht gefunden'}), 404 + + is_owner = (f.owner_id == user.id) + my_perm, my_reshare = _effective_permission(f, user) + if not is_owner and not my_reshare: + return jsonify({'error': 'Zugriff verweigert'}), 403 + + # Owners see everyone; re-sharers only see perms they granted themselves. + if is_owner: + perms = FilePermission.query.filter_by(file_id=file_id).all() + else: + perms = FilePermission.query.filter_by(file_id=file_id, granted_by=user.id).all() - perms = FilePermission.query.filter_by(file_id=file_id).all() from app.models.user import User result = [] for p in perms: @@ -558,6 +596,8 @@ def get_permissions(file_id): 'user_id': p.user_id, 'username': u.username if u else None, 'permission': p.permission, + 'can_reshare': bool(p.can_reshare), + 'granted_by': p.granted_by, }) return jsonify(result), 200 @@ -567,29 +607,60 @@ def get_permissions(file_id): def set_permission(file_id): user = request.current_user f = db.session.get(File, file_id) - if not f or f.owner_id != user.id: - return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen setzen'}), 403 + if not f: + return jsonify({'error': 'Datei nicht gefunden'}), 404 + + is_owner = (f.owner_id == user.id) + my_perm, my_reshare = _effective_permission(f, user) + if not is_owner and not my_reshare: + return jsonify({'error': 'Keine Berechtigung zum Weiterteilen'}), 403 data = request.get_json() target_user_id = data.get('user_id') permission = data.get('permission', 'read') + can_reshare_req = bool(data.get('can_reshare', False)) if permission not in ('read', 'write', 'admin'): return jsonify({'error': 'Ungueltige Berechtigung'}), 400 + # Re-sharers can't hand out more than they have themselves. + levels = {'read': 0, 'write': 1, 'admin': 2} + if not is_owner: + max_allowed = levels.get(my_perm, -1) + if levels.get(permission, -1) > max_allowed: + return jsonify({ + 'error': f'Du kannst hoechstens "{my_perm}" weiterverteilen' + }), 403 + if permission == 'admin': + return jsonify({'error': 'Admin-Recht kann nur der Eigentuemer vergeben'}), 403 + from app.models.user import User target = db.session.get(User, target_user_id) if not target: return jsonify({'error': 'Benutzer nicht gefunden'}), 404 + if target.id == f.owner_id: + return jsonify({'error': 'Eigentuemer hat bereits Vollzugriff'}), 400 existing = FilePermission.query.filter_by( file_id=file_id, user_id=target_user_id ).first() is_new = not existing if existing: + # Re-sharers may only modify perms they themselves granted + if not is_owner and existing.granted_by != user.id: + return jsonify({'error': 'Diese Freigabe wurde von jemand anderem erstellt'}), 403 existing.permission = permission + existing.can_reshare = can_reshare_req + if is_new or existing.granted_by is None: + existing.granted_by = user.id else: - perm = FilePermission(file_id=file_id, user_id=target_user_id, permission=permission) + perm = FilePermission( + file_id=file_id, + user_id=target_user_id, + permission=permission, + can_reshare=can_reshare_req, + granted_by=user.id, + ) db.session.add(perm) db.session.commit() @@ -610,13 +681,17 @@ def set_permission(file_id): def remove_permission(file_id, perm_id): user = request.current_user f = db.session.get(File, file_id) - if not f or f.owner_id != user.id: - return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen entfernen'}), 403 + if not f: + return jsonify({'error': 'Datei nicht gefunden'}), 404 perm = db.session.get(FilePermission, perm_id) if not perm or perm.file_id != file_id: return jsonify({'error': 'Berechtigung nicht gefunden'}), 404 + is_owner = (f.owner_id == user.id) + if not is_owner and perm.granted_by != user.id: + return jsonify({'error': 'Du kannst nur selbst erstellte Freigaben entfernen'}), 403 + db.session.delete(perm) db.session.commit() return jsonify({'message': 'Berechtigung entfernt'}), 200 @@ -628,9 +703,14 @@ def remove_permission(file_id, perm_id): @token_required def create_share_link(file_id): user = request.current_user - f, err = _get_file_or_403(file_id, user, 'read') - if err: - return err + f = db.session.get(File, file_id) + if not f: + return jsonify({'error': 'Datei nicht gefunden'}), 404 + + is_owner = (f.owner_id == user.id) + my_perm, my_reshare = _effective_permission(f, user) + if not is_owner and not my_reshare: + return jsonify({'error': 'Keine Berechtigung zum Weiterteilen'}), 403 data = request.get_json() or {} password = data.get('password') @@ -641,6 +721,18 @@ def create_share_link(file_id): if permission not in ('read', 'write', 'upload_only'): return jsonify({'error': 'Berechtigung muss "read", "write" oder "upload_only" sein'}), 400 + # Re-sharers can only hand out what they have themselves. + if not is_owner: + levels = {'read': 0, 'write': 1} + max_allowed = levels.get(my_perm, -1) + requested = levels.get(permission, 99) + if requested > max_allowed: + return jsonify({ + 'error': f'Du hast selbst nur "{my_perm}" - kannst nicht schreibend weiterteilen' + }), 403 + if permission == 'upload_only' and my_perm not in ('write', 'admin'): + return jsonify({'error': 'Upload-Links nur mit Schreibrecht moeglich'}), 403 + token = secrets.token_urlsafe(32) password_hash = None if password: diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 380cddc..7a55afc 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -55,8 +55,11 @@ class FilePermission(db.Model): file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) permission = db.Column(db.String(20), nullable=False) # 'read', 'write', 'admin' + can_reshare = db.Column(db.Boolean, default=False, nullable=False) + granted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - user = db.relationship('User', backref='file_permissions') + user = db.relationship('User', foreign_keys=[user_id], backref='file_permissions') + grantor = db.relationship('User', foreign_keys=[granted_by]) __table_args__ = ( db.UniqueConstraint('file_id', 'user_id', name='uq_file_user_permission'), diff --git a/frontend/src/views/FilesView.vue b/frontend/src/views/FilesView.vue index dbec2ad..e1769c4 100644 --- a/frontend/src/views/FilesView.vue +++ b/frontend/src/views/FilesView.vue @@ -94,6 +94,7 @@ @click.stop="downloadFile(data)" />