import os import uuid import hashlib import secrets import mimetypes from datetime import datetime, timezone from pathlib import Path from flask import request, jsonify, send_file, current_app 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 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.""" if file_obj.owner_id == user.id: return True perm = FilePermission.query.filter_by( file_id=file_obj.id, user_id=user.id ).first() if not perm: return False perm_levels = {'read': 0, 'write': 1, 'admin': 2} return perm_levels.get(perm.permission, -1) >= perm_levels.get(permission, 0) 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) # Own files in this folder query = File.query.filter_by(owner_id=user.id, parent_id=parent_id) files = query.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.parent_id.is_(None) ).order_by(File.is_folder.desc(), File.name).all() result = [f.to_dict() for f in files] for f in shared: d = f.to_dict() d['shared'] = True 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() 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() 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() 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 jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400 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 return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True, download_name=f.name) # --- 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 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() return jsonify(f.to_dict()), 200 # --- Delete --- @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: # Owner can always delete f = db.session.get(File, file_id) if not f or f.owner_id != user.id: return jsonify({'error': 'Zugriff verweigert'}), 403 _delete_recursive(f, user.id) db.session.commit() return jsonify({'message': 'Geloescht'}), 200 def _delete_recursive(file_obj, user_id): if file_obj.is_folder: children = File.query.filter_by(parent_id=file_obj.id).all() for child in children: _delete_recursive(child, user_id) 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) # --- Permissions --- @api_bp.route('/files//permissions', methods=['GET']) @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 perms = FilePermission.query.filter_by(file_id=file_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, }) 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 or f.owner_id != user.id: return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen setzen'}), 403 data = request.get_json() target_user_id = data.get('user_id') permission = data.get('permission', 'read') if permission not in ('read', 'write', 'admin'): return jsonify({'error': 'Ungueltige Berechtigung'}), 400 from app.models.user import User target = db.session.get(User, target_user_id) if not target: return jsonify({'error': 'Benutzer nicht gefunden'}), 404 existing = FilePermission.query.filter_by( file_id=file_id, user_id=target_user_id ).first() is_new = not existing if existing: existing.permission = permission else: perm = FilePermission(file_id=file_id, user_id=target_user_id, permission=permission) db.session.add(perm) db.session.commit() # 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 or f.owner_id != user.id: return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen entfernen'}), 403 perm = db.session.get(FilePermission, perm_id) if not perm or perm.file_id != file_id: return jsonify({'error': 'Berechtigung nicht gefunden'}), 404 db.session.delete(perm) db.session.commit() 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, err = _get_file_or_403(file_id, user, 'read') if err: return err 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'): return jsonify({'error': 'Berechtigung muss "read" oder "write" sein'}), 400 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 == 'write', }), 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.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 permission if link.permission != 'write': return jsonify({'error': 'Dieser Link erlaubt nur Lesen'}), 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 # --- Sync API --- @api_bp.route('/sync/tree', methods=['GET']) @token_required def sync_tree(): """Returns complete file tree with checksums for sync clients.""" user = request.current_user def _build_tree(parent_id): files = File.query.filter_by(owner_id=user.id, parent_id=parent_id)\ .order_by(File.is_folder.desc(), File.name).all() result = [] for f in files: 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, } if f.is_folder: entry['children'] = _build_tree(f.id) result.append(entry) return result return jsonify({'tree': _build_tree(None)}), 200 @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