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, Response from app.api import api_bp from app.api.auth import token_required from app.extensions import db, bcrypt from app.models.file import File, FilePermission, ShareLink from app.models.file_lock import FileLock from app.services.events import broadcaster, notify_file_change def _share_recipients(file_obj): """Return a list of user ids (besides the owner) that should see changes to this file because they have a direct share permission on it or on any of its ancestor folders.""" ids = set() cur = file_obj while cur is not None: for p in FilePermission.query.filter_by(file_id=cur.id).all(): ids.add(p.user_id) cur = cur.parent ids.discard(file_obj.owner_id) 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) user_dir.mkdir(parents=True, exist_ok=True) return user_dir def _check_file_access(file_obj, user, permission='read'): """Check if user has access to file. Owner always has full access. A permission on an ancestor folder also grants access to all descendants.""" if file_obj.owner_id == user.id: return True perm_levels = {'read': 0, 'write': 1, 'admin': 2} needed = perm_levels.get(permission, 0) # Walk up the tree looking for a permission on this file or any ancestor cur = file_obj while cur is not None: perm = FilePermission.query.filter_by( file_id=cur.id, user_id=user.id ).first() if perm and perm_levels.get(perm.permission, -1) >= needed: return True cur = cur.parent return False def _get_file_or_403(file_id, user, permission='read'): f = db.session.get(File, file_id) if not f: return None, (jsonify({'error': 'Datei nicht gefunden'}), 404) if not _check_file_access(f, user, permission): return None, (jsonify({'error': 'Zugriff verweigert'}), 403) return f, None def _compute_checksum(filepath): h = hashlib.sha256() with open(filepath, 'rb') as fh: for chunk in iter(lambda: fh.read(8192), b''): h.update(chunk) return h.hexdigest() # --- Folder / File listing --- @api_bp.route('/files', methods=['GET']) @token_required def list_files(): user = request.current_user parent_id = request.args.get('parent_id', None, type=int) # When browsing into a folder, verify access first. If the folder is # shared with us (directly or via an ancestor), list ALL its children # - not just ones owned by us. if parent_id is not None: parent_folder, perr = _get_file_or_403(parent_id, user, 'read') if perr: return perr if parent_folder.owner_id == user.id: files = File.query.filter_by( owner_id=user.id, parent_id=parent_id, is_trashed=False ).order_by(File.is_folder.desc(), File.name).all() else: files = File.query.filter_by( parent_id=parent_id, is_trashed=False ).order_by(File.is_folder.desc(), File.name).all() else: files = File.query.filter_by( owner_id=user.id, parent_id=None, is_trashed=False ).order_by(File.is_folder.desc(), File.name).all() # Shared files at root level shared = [] if parent_id is None: shared_perms = FilePermission.query.filter_by(user_id=user.id).all() shared_file_ids = [p.file_id for p in shared_perms] if shared_file_ids: shared = File.query.filter( File.id.in_(shared_file_ids), File.is_trashed == False # noqa: E712 ).order_by(File.is_folder.desc(), File.name).all() 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 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 d['locked_by'] = lock.user.username d['locked_at'] = lock.locked_at.isoformat() result.append(d) 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 breadcrumb = [] if parent_id: current = db.session.get(File, parent_id) while current: breadcrumb.insert(0, {'id': current.id, 'name': current.name}) current = current.parent return jsonify({'files': result, 'breadcrumb': breadcrumb}), 200 @api_bp.route('/files/folder', methods=['POST']) @token_required def create_folder(): user = request.current_user data = request.get_json() name = data.get('name', '').strip() parent_id = data.get('parent_id', None) if not name: return jsonify({'error': 'Ordnername erforderlich'}), 400 if parent_id: parent, err = _get_file_or_403(parent_id, user, 'write') if err: return err if not parent.is_folder: return jsonify({'error': 'Uebergeordnetes Element ist kein Ordner'}), 400 existing = File.query.filter_by( owner_id=user.id, parent_id=parent_id, name=name, is_folder=True ).first() if existing: return jsonify({'error': 'Ordner existiert bereits'}), 409 folder = File( owner_id=user.id, parent_id=parent_id, name=name, is_folder=True, ) db.session.add(folder) db.session.commit() notify_file_change(folder.owner_id, folder.id, 'created', shared_with=_share_recipients(folder)) return jsonify(folder.to_dict()), 201 def _ensure_folder_path(user_id, parent_id, path_parts): """Create nested folder structure. Returns the ID of the deepest folder.""" current_parent = parent_id for part in path_parts: part = part.strip() if not part: continue existing = File.query.filter_by( owner_id=user_id, parent_id=current_parent, name=part, is_folder=True ).first() if existing: current_parent = existing.id else: folder = File(owner_id=user_id, parent_id=current_parent, name=part, is_folder=True) db.session.add(folder) db.session.flush() current_parent = folder.id return current_parent @api_bp.route('/files/ensure-path', methods=['POST']) @token_required def ensure_folder_path(): """Create nested folder structure from a path string like 'Docs/Work/Project'. Returns the ID of the deepest folder.""" user = request.current_user data = request.get_json() path = data.get('path', '').strip().strip('/') parent_id = data.get('parent_id', None) if not path: return jsonify({'folder_id': parent_id}), 200 parts = [p for p in path.split('/') if p.strip()] folder_id = _ensure_folder_path(user.id, parent_id, parts) db.session.commit() return jsonify({'folder_id': folder_id}), 200 # --- Upload --- @api_bp.route('/files/upload', methods=['POST']) @token_required def upload_file(): user = request.current_user parent_id = request.form.get('parent_id', None, type=int) if parent_id: parent, err = _get_file_or_403(parent_id, user, 'write') if err: return err 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' # Save to disk with UUID name storage_name = str(uuid.uuid4()) user_dir = _user_upload_dir(user.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)) # Check if file with same name exists -> overwrite existing = File.query.filter_by( owner_id=user.id, parent_id=parent_id, name=filename, is_folder=False ).first() if existing: # Remove old file from disk old_path = Path(current_app.config['UPLOAD_PATH']) / str(user.id) / existing.storage_path if old_path.exists(): old_path.unlink() existing.storage_path = storage_name existing.size = size existing.mime_type = mime existing.checksum = checksum existing.updated_at = datetime.now(timezone.utc) db.session.commit() notify_file_change(existing.owner_id, existing.id, 'updated', shared_with=_share_recipients(existing)) return jsonify(existing.to_dict()), 200 file_obj = File( owner_id=user.id, parent_id=parent_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_file_change(file_obj.owner_id, file_obj.id, 'created', shared_with=_share_recipients(file_obj)) return jsonify(file_obj.to_dict()), 201 # --- Download --- @api_bp.route('/files//download', methods=['GET']) @token_required def download_file(file_id): user = request.current_user f, err = _get_file_or_403(file_id, user, 'read') if err: return err if f.is_folder: return _download_folder_as_zip(f) filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path if not filepath.exists(): return jsonify({'error': 'Datei auf Datentraeger nicht gefunden'}), 404 # inline=1 renders the file in-browser (used by PDF/image previews). # Default is attachment so normal download buttons still save to disk. inline = request.args.get('inline', '0') == '1' return send_file(str(filepath), mimetype=f.mime_type, as_attachment=not inline, 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/', methods=['PUT']) @token_required def update_file(file_id): user = request.current_user f, err = _get_file_or_403(file_id, user, 'write') if err: return err # Lock-Check: fremder Lock blockiert Aenderungen (admin kann durch) lock = FileLock.get_lock(file_id) if lock and lock.locked_by != user.id and user.role != 'admin': return jsonify({'error': f'Datei ist von {lock.user.username} ausgecheckt'}), 423 data = request.get_json() if 'name' in data: name = data['name'].strip() if name: f.name = name if 'parent_id' in data: new_parent = data['parent_id'] if new_parent is not None: parent, perr = _get_file_or_403(new_parent, user, 'write') if perr: return perr if not parent.is_folder: return jsonify({'error': 'Ziel ist kein Ordner'}), 400 # Prevent moving folder into itself if f.is_folder: check = parent while check: if check.id == f.id: return jsonify({'error': 'Ordner kann nicht in sich selbst verschoben werden'}), 400 check = check.parent f.parent_id = new_parent f.updated_at = datetime.now(timezone.utc) db.session.commit() notify_file_change(f.owner_id, f.id, 'updated', shared_with=_share_recipients(f)) return jsonify(f.to_dict()), 200 # --- Delete (soft-delete -> trash) --- @api_bp.route('/files/', methods=['DELETE']) @token_required def delete_file(file_id): user = request.current_user f, err = _get_file_or_403(file_id, user, 'admin') if err: f = db.session.get(File, file_id) if not f or f.owner_id != user.id: return jsonify({'error': 'Zugriff verweigert'}), 403 # Lock-Check lock = FileLock.get_lock(file_id) if lock and lock.locked_by != user.id and user.role != 'admin': return jsonify({'error': f'Datei ist von {lock.user.username} ausgecheckt'}), 423 # Capture recipients BEFORE we detach the file from its parent tree recipients = _share_recipients(f) owner_id = f.owner_id # Soft-delete: move to trash _trash_recursive(f) db.session.commit() notify_file_change(owner_id, f.id, 'deleted', shared_with=recipients) return jsonify({'message': 'In Papierkorb verschoben'}), 200 def _trash_recursive(file_obj): """Move file/folder to trash (soft-delete).""" now = datetime.now(timezone.utc) if not file_obj.is_trashed: file_obj.original_parent_id = file_obj.parent_id file_obj.parent_id = None file_obj.is_trashed = True file_obj.trashed_at = now if file_obj.is_folder: children = File.query.filter_by(parent_id=file_obj.id, is_trashed=False).all() for child in children: child.is_trashed = True child.trashed_at = now def _delete_permanent(file_obj): """Permanently delete a file/folder and its disk data.""" if file_obj.is_folder: children = File.query.filter_by(parent_id=file_obj.id).all() for child in children: _delete_permanent(child) else: if file_obj.storage_path: filepath = Path(current_app.config['UPLOAD_PATH']) / str(file_obj.owner_id) / file_obj.storage_path if filepath.exists(): filepath.unlink() db.session.delete(file_obj) # --- Trash / Papierkorb --- @api_bp.route('/files/trash', methods=['GET']) @token_required def list_trash(): """List all trashed files for the current user.""" user = request.current_user trashed = File.query.filter_by(owner_id=user.id, is_trashed=True)\ .filter(File.original_parent_id.isnot(None) | (File.original_parent_id.is_(None)))\ .order_by(File.trashed_at.desc()).all() # Only show top-level trashed items (not children of trashed folders) top_level = [] trashed_folder_ids = {f.id for f in trashed if f.is_folder} for f in trashed: # Show if parent is not also trashed parent_trashed = False if f.original_parent_id: parent = db.session.get(File, f.original_parent_id) if parent and parent.is_trashed: parent_trashed = True if not parent_trashed: top_level.append(f.to_dict()) return jsonify(top_level), 200 @api_bp.route('/files/trash//restore', methods=['POST']) @token_required def restore_from_trash(file_id): """Restore a file/folder from trash.""" user = request.current_user f = db.session.get(File, file_id) if not f or f.owner_id != user.id or not f.is_trashed: return jsonify({'error': 'Nicht gefunden'}), 404 # Restore to original parent (or root if parent no longer exists) original_parent = db.session.get(File, f.original_parent_id) if f.original_parent_id else None if original_parent and not original_parent.is_trashed: f.parent_id = f.original_parent_id else: f.parent_id = None f.is_trashed = False f.trashed_at = None f.original_parent_id = None # Also restore children if f.is_folder: _restore_children(f) db.session.commit() return jsonify({'message': 'Wiederhergestellt'}), 200 def _restore_children(folder): children = File.query.filter_by(is_trashed=True).all() for child in children: # Check if this child was inside the restored folder if child.original_parent_id == folder.id or child.parent_id == folder.id: child.is_trashed = False child.trashed_at = None child.parent_id = folder.id child.original_parent_id = None if child.is_folder: _restore_children(child) @api_bp.route('/files/trash/', methods=['DELETE']) @token_required def delete_permanently(file_id): """Permanently delete a trashed file.""" user = request.current_user f = db.session.get(File, file_id) if not f or f.owner_id != user.id or not f.is_trashed: return jsonify({'error': 'Nicht gefunden'}), 404 _delete_permanent(f) db.session.commit() return jsonify({'message': 'Endgueltig geloescht'}), 200 @api_bp.route('/files/trash/empty', methods=['POST']) @token_required def empty_trash(): """Permanently delete all trashed files.""" user = request.current_user trashed = File.query.filter_by(owner_id=user.id, is_trashed=True).all() for f in trashed: _delete_permanent(f) db.session.commit() return jsonify({'message': 'Papierkorb geleert'}), 200 # --- Permissions --- @api_bp.route('/files//permissions', methods=['GET']) @token_required def get_permissions(file_id): user = request.current_user 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() from app.models.user import User result = [] for p in perms: u = db.session.get(User, p.user_id) result.append({ 'id': p.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 @api_bp.route('/files//permissions', methods=['POST']) @token_required def set_permission(file_id): user = request.current_user 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() 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, can_reshare=can_reshare_req, granted_by=user.id, ) db.session.add(perm) db.session.commit() # SSE: notify target user (they just got/updated access) + owner + other # share recipients so everyone's file list refreshes. notify_file_change(f.owner_id, f.id, 'permission', shared_with=[target.id, *_share_recipients(f)]) # Notify user via email if is_new: try: from app.services.system_mail import notify_file_shared_with_user notify_file_shared_with_user(f.name, user.username, target) except Exception: pass return jsonify({'message': 'Berechtigung gesetzt'}), 200 @api_bp.route('/files//permissions/', methods=['DELETE']) @token_required def remove_permission(file_id, perm_id): user = request.current_user f = db.session.get(File, file_id) 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 target_user_id = perm.user_id db.session.delete(perm) db.session.commit() notify_file_change(f.owner_id, f.id, 'permission', shared_with=[target_user_id, *_share_recipients(f)]) return jsonify({'message': 'Berechtigung entfernt'}), 200 # --- Share Links --- @api_bp.route('/files//share', methods=['POST']) @token_required def create_share_link(file_id): user = request.current_user 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') expires_at = data.get('expires_at') max_downloads = data.get('max_downloads') permission = data.get('permission', 'read') 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: password_hash = bcrypt.generate_password_hash(password).decode('utf-8') exp_dt = None if expires_at: try: exp_dt = datetime.fromisoformat(expires_at).replace(tzinfo=timezone.utc) except ValueError: return jsonify({'error': 'Ungueltiges Datumsformat'}), 400 link = ShareLink( file_id=file_id, token=token, permission=permission, password_hash=password_hash, expires_at=exp_dt, created_by=user.id, max_downloads=max_downloads, ) db.session.add(link) db.session.commit() return jsonify({ 'token': token, 'url': f'/share/{token}', 'permission': permission, 'expires_at': exp_dt.isoformat() if exp_dt else None, 'has_password': bool(password), }), 201 @api_bp.route('/files//shares', methods=['GET']) @token_required def list_share_links(file_id): user = request.current_user f, err = _get_file_or_403(file_id, user, 'read') if err: return err links = ShareLink.query.filter_by(file_id=file_id).all() return jsonify([{ 'id': l.id, 'token': l.token, 'permission': l.permission, 'has_password': bool(l.password_hash), 'expires_at': l.expires_at.isoformat() if l.expires_at else None, 'download_count': l.download_count, 'max_downloads': l.max_downloads, 'created_at': l.created_at.isoformat(), } for l in links]), 200 @api_bp.route('/share//info', methods=['GET']) def share_info(token): 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 if link.is_download_limit_reached(): return jsonify({'error': 'Download-Limit erreicht'}), 410 f = db.session.get(File, link.file_id) return jsonify({ 'name': f.name, 'is_folder': f.is_folder, 'size': f.size, 'mime_type': f.mime_type, 'has_password': bool(link.password_hash), 'permission': link.permission, 'upload_allowed': f.is_folder and link.permission in ('write', 'upload_only'), 'download_allowed': link.permission in ('read', 'write'), }), 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 or subfolder.""" 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 if link.permission == 'upload_only': return jsonify({'error': 'Dieser Link erlaubt keinen Einblick'}), 403 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 shared_folder = db.session.get(File, link.file_id) if not shared_folder.is_folder: return jsonify({'error': 'Kein Ordner'}), 400 # 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, is_trashed=False)\ .order_by(File.is_folder.desc(), File.name).all() # 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']) def share_download_file(token, file_id): """Download a specific file from a shared 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 if link.permission not in ('read', 'write'): return jsonify({'error': 'Download nicht erlaubt'}), 403 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 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 (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: return jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400 filepath = Path(current_app.config['UPLOAD_PATH']) / str(target_file.owner_id) / target_file.storage_path if not filepath.exists(): return jsonify({'error': 'Datei nicht gefunden'}), 404 link.download_count += 1 db.session.commit() return send_file(str(filepath), mimetype=target_file.mime_type, as_attachment=True, download_name=target_file.name) @api_bp.route('/share//download-zip', methods=['GET']) def share_download_zip(token): """Download the entire shared folder as ZIP.""" 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 if link.permission not in ('read', 'write'): return jsonify({'error': 'Download nicht erlaubt'}), 403 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: return jsonify({'error': 'Kein Ordner'}), 400 link.download_count += 1 db.session.commit() try: from app.services.system_mail import notify_share_link_accessed notify_share_link_accessed(link, f.name, request.remote_addr) except Exception: pass return _download_folder_as_zip(f) @api_bp.route('/share//files/', methods=['DELETE']) def share_delete_file(token, file_id): """Delete a file from a shared folder (write permission required).""" 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 if link.permission != 'write': return jsonify({'error': 'Loeschen nicht erlaubt'}), 403 if link.password_hash: password = request.headers.get('X-Share-Password', '') if not bcrypt.check_password_hash(link.password_hash, password): return jsonify({'error': 'Passwort erforderlich'}), 401 target_file = db.session.get(File, file_id) if not target_file: return jsonify({'error': 'Datei nicht gefunden'}), 404 if not _is_inside_shared_folder(target_file, link.file_id): return jsonify({'error': 'Datei gehoert nicht zu diesem Ordner'}), 403 # Soft-delete: move to trash _trash_recursive(target_file) db.session.commit() return jsonify({'message': 'In Papierkorb verschoben'}), 200 @api_bp.route('/share//verify', methods=['POST']) def share_verify(token): 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 data = request.get_json() or {} password = data.get('password', '') if link.password_hash: if not bcrypt.check_password_hash(link.password_hash, password): return jsonify({'error': 'Falsches Passwort'}), 401 # Generate temporary download token download_token = secrets.token_urlsafe(16) # Store in link temporarily (simple approach) link._download_token = download_token return jsonify({'download_token': download_token}), 200 @api_bp.route('/share//download', methods=['GET']) def share_download(token): link = ShareLink.query.filter_by(token=token).first() if not link: return jsonify({'error': 'Link nicht gefunden'}), 404 if link.permission == 'upload_only': return jsonify({'error': 'Dieser Link erlaubt nur Upload, keinen Download'}), 403 if link.is_expired(): return jsonify({'error': 'Link abgelaufen'}), 410 if link.is_download_limit_reached(): return jsonify({'error': 'Download-Limit erreicht'}), 410 # Check password if set if link.password_hash: # For password-protected links, require the password as query param or header 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 f.is_folder: return jsonify({'error': 'Ordner-Download noch nicht implementiert'}), 501 filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path if not filepath.exists(): return jsonify({'error': 'Datei nicht gefunden'}), 404 link.download_count += 1 db.session.commit() # Notify creator about download try: from app.services.system_mail import notify_share_link_accessed notify_share_link_accessed(link, f.name, request.remote_addr) except Exception: pass return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True, 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 with write permission).""" 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 write/upload permission if link.permission not in ('write', 'upload_only'): return jsonify({'error': 'Dieser Link erlaubt keinen Upload'}), 403 # 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): user = request.current_user link = ShareLink.query.filter_by(token=token).first() if not link: return jsonify({'error': 'Link nicht gefunden'}), 404 if link.created_by != user.id: return jsonify({'error': 'Nur der Ersteller kann den Link loeschen'}), 403 db.session.delete(link) db.session.commit() return jsonify({'message': 'Link geloescht'}), 200 # --- File Locking --- @api_bp.route('/files//lock', methods=['POST']) @token_required def lock_file(file_id): """Lock a file (check out). Prevents others from opening/editing.""" user = request.current_user f = db.session.get(File, file_id) if not f: return jsonify({'error': 'Datei nicht gefunden'}), 404 # Check existing lock existing = FileLock.get_lock(file_id) if existing: if existing.locked_by == user.id: # Already locked by this user - refresh heartbeat existing.heartbeat_at = datetime.now(timezone.utc) db.session.commit() return jsonify(existing.to_dict()), 200 return jsonify({ 'error': f'Datei wird von {existing.user.username} bearbeitet', 'locked_by': existing.user.username, 'locked_at': existing.locked_at.isoformat(), }), 423 # 423 Locked data = request.get_json(silent=True) or {} lock = FileLock( file_id=file_id, locked_by=user.id, client_info=data.get('client_info', ''), ) db.session.add(lock) db.session.commit() notify_file_change(f.owner_id, f.id, 'locked', shared_with=_share_recipients(f)) return jsonify(lock.to_dict()), 200 @api_bp.route('/files//unlock', methods=['POST']) @token_required def unlock_file(file_id): """Unlock a file (check in).""" user = request.current_user lock = FileLock.get_lock(file_id) if not lock: return jsonify({'message': 'Datei war nicht gesperrt'}), 200 if lock.locked_by != user.id and user.role != 'admin': return jsonify({'error': 'Nur der Sperrer oder ein Admin kann entsperren'}), 403 db.session.delete(lock) db.session.commit() f = db.session.get(File, file_id) if f: notify_file_change(f.owner_id, f.id, 'unlocked', shared_with=_share_recipients(f)) return jsonify({'message': 'Datei entsperrt'}), 200 @api_bp.route('/files//heartbeat', methods=['POST']) @token_required def heartbeat_file(file_id): """Heartbeat - signal that the file is still being edited.""" user = request.current_user lock = FileLock.get_lock(file_id) if not lock: return jsonify({'error': 'Keine Sperre vorhanden'}), 404 if lock.locked_by != user.id: return jsonify({'error': 'Sperre gehoert einem anderen Benutzer'}), 403 lock.heartbeat_at = datetime.now(timezone.utc) db.session.commit() return jsonify({'message': 'Heartbeat aktualisiert'}), 200 @api_bp.route('/files//lock-status', methods=['GET']) @token_required def lock_status(file_id): """Check if a file is locked.""" lock = FileLock.get_lock(file_id) if not lock: return jsonify({'locked': False}), 200 return jsonify({ 'locked': True, 'locked_by': lock.user.username, 'locked_by_id': lock.locked_by, 'locked_at': lock.locked_at.isoformat(), 'client_info': lock.client_info, }), 200 @api_bp.route('/files/locks', methods=['GET']) @token_required def list_locks(): """List all active locks (for admin overview or sync clients).""" # Cleanup expired first FileLock.cleanup_expired() locks = FileLock.query.all() return jsonify([l.to_dict() for l in locks]), 200 # --- Sync API --- @api_bp.route('/sync/tree', methods=['GET']) @token_required def sync_tree(): """Returns complete file tree with checksums for sync clients. Includes both files owned by the user (under 'tree') and files shared WITH the user (as a virtual 'Geteilt mit mir' folder under 'shared'). The client merges both. """ user = request.current_user def _entry(f): entry = { 'id': f.id, 'name': f.name, 'is_folder': f.is_folder, 'size': f.size, 'checksum': f.checksum, 'updated_at': f.updated_at.isoformat() if f.updated_at else None, 'modified_at': f.updated_at.isoformat() if f.updated_at else None, } lock = FileLock.get_lock(f.id) if lock: entry['locked'] = True entry['locked_by'] = lock.user.username return entry def _build_tree(parent_id): files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\ .order_by(File.is_folder.desc(), File.name).all() result = [] for f in files: e = _entry(f) if f.is_folder: e['children'] = _build_tree(f.id) result.append(e) return result def _build_shared_children(parent_id): files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\ .order_by(File.is_folder.desc(), File.name).all() out = [] for f in files: e = _entry(f) if f.is_folder: e['children'] = _build_shared_children(f.id) out.append(e) return out shared_perms = FilePermission.query.filter_by(user_id=user.id).all() shared_roots = [] seen = set() for perm in shared_perms: f = perm.file if not f or f.is_trashed or f.id in seen: continue seen.add(f.id) # Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt # ist, ist dieses Item die Wurzel des Shares beim Empfaenger. parent_shared = any( p.file_id == f.parent_id for p in shared_perms ) if f.parent_id else False if parent_shared: continue e = _entry(f) owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None if owner: e['name'] = f'{f.name} (von {owner})' if f.is_folder: e['children'] = _build_shared_children(f.id) shared_roots.append(e) return jsonify({ 'tree': _build_tree(None), 'shared': shared_roots, }), 200 @api_bp.route('/sync/events', methods=['GET']) @token_required def sync_events(): """Server-Sent Events stream: real-time file change notifications.""" user = request.current_user user_id = user.id def event_stream(): yield from broadcaster.stream(user_id) resp = Response(event_stream(), mimetype='text/event-stream') resp.headers['Cache-Control'] = 'no-cache' resp.headers['X-Accel-Buffering'] = 'no' # disable nginx buffering resp.headers['Connection'] = 'keep-alive' return resp @api_bp.route('/sync/changes', methods=['GET']) @token_required def sync_changes(): """Returns files changed since a given timestamp.""" user = request.current_user since = request.args.get('since') if not since: return jsonify({'error': 'Parameter "since" erforderlich'}), 400 try: since_dt = datetime.fromisoformat(since).replace(tzinfo=timezone.utc) except ValueError: return jsonify({'error': 'Ungueltiges Datumsformat'}), 400 changed = File.query.filter( File.owner_id == user.id, File.updated_at > since_dt ).all() return jsonify({ 'changes': [f.to_dict() for f in changed], 'server_time': datetime.now(timezone.utc).isoformat(), }), 200