From 748537b9f5500d717ceeacc6cc6eea952b79986d Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 23:20:55 +0200 Subject: [PATCH] feat: File Locking System (Ein-/Auschecken) + Konflikt-Email Backend - FileLock Model + API: - POST /files//lock - Datei auschecken (sperren) - POST /files//unlock - Datei einchecken (entsperren) - POST /files//heartbeat - "Datei noch offen" (alle 60s) - GET /files//lock-status - Sperrstatus abfragen - GET /files/locks - Alle aktiven Sperren auflisten - Auto-Unlock: Kein Heartbeat seit 5 Min -> Sperre wird freigegeben - 423 Locked wenn bereits von anderem User gesperrt - Admin kann fremde Sperren aufheben Dateiliste + Sync-API: - Lock-Info (locked, locked_by, locked_at) pro Datei mitgeliefert - Sync-Tree enthaelt Lock-Status fuer Desktop/Mobile-Clients Web-UI: - Schloss-Icon mit Benutzername bei gesperrten Dateien - Tooltip: "Ausgecheckt von Adam seit 14:30" - Gesperrte Dateien: "Oeffnen nicht moeglich" Toast-Meldung (eigene Sperren sind erlaubt) Konflikt-Email an Admin: - Wer hat die Konflikt-Kopie erstellt (Name + Email) - Welche Datei (Name + Ordnerpfad) - Name der Konflikt-Kopie - Von wem gesperrt (Name + Email + seit wann) - Erklaerungstext was passiert ist Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/files.py | 108 ++++++++++++++++++++++++++++ backend/app/models/__init__.py | 1 + backend/app/models/file_lock.py | 58 +++++++++++++++ backend/app/services/system_mail.py | 29 ++++++++ frontend/src/views/FilesView.vue | 20 ++++++ 5 files changed, 216 insertions(+) create mode 100644 backend/app/models/file_lock.py diff --git a/backend/app/api/files.py b/backend/app/api/files.py index fecbbbf..46884d4 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -15,6 +15,7 @@ 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 def _user_upload_dir(user_id): @@ -82,6 +83,11 @@ 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 + 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() @@ -975,6 +981,104 @@ def delete_share_link(token): 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() + 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() + 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']) @@ -996,6 +1100,10 @@ def sync_tree(): 'checksum': f.checksum, 'updated_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 if f.is_folder: entry['children'] = _build_tree(f.id) result.append(entry) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fce0e19..b8df9a8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.email_account import EmailAccount from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare from app.models.settings import AppSettings from app.models.backup_target import BackupTarget +from app.models.file_lock import FileLock __all__ = [ 'User', diff --git a/backend/app/models/file_lock.py b/backend/app/models/file_lock.py new file mode 100644 index 0000000..8e8698c --- /dev/null +++ b/backend/app/models/file_lock.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone, timedelta + +from app.extensions import db + +# Lock expires after 5 minutes without heartbeat +LOCK_TIMEOUT_MINUTES = 5 + + +class FileLock(db.Model): + __tablename__ = 'file_locks' + + id = db.Column(db.Integer, primary_key=True) + file_id = db.Column(db.Integer, db.ForeignKey('files.id'), unique=True, nullable=False, index=True) + locked_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + locked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + heartbeat_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + client_info = db.Column(db.String(255), nullable=True) # e.g. "Desktop-Client Windows" + + file = db.relationship('File', backref=db.backref('lock', uselist=False)) + user = db.relationship('User', backref='file_locks') + + def is_expired(self): + cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES) + return self.heartbeat_at.replace(tzinfo=timezone.utc) < cutoff + + def to_dict(self): + return { + 'id': self.id, + 'file_id': self.file_id, + 'locked_by': self.locked_by, + 'locked_by_username': self.user.username if self.user else None, + 'locked_at': self.locked_at.isoformat() if self.locked_at else None, + 'heartbeat_at': self.heartbeat_at.isoformat() if self.heartbeat_at else None, + 'client_info': self.client_info, + 'is_expired': self.is_expired(), + } + + @staticmethod + def cleanup_expired(): + """Remove all expired locks.""" + cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES) + expired = FileLock.query.filter(FileLock.heartbeat_at < cutoff).all() + count = len(expired) + for lock in expired: + db.session.delete(lock) + if count: + db.session.commit() + return count + + @staticmethod + def get_lock(file_id): + """Get active (non-expired) lock for a file, cleaning up expired ones.""" + lock = FileLock.query.filter_by(file_id=file_id).first() + if lock and lock.is_expired(): + db.session.delete(lock) + db.session.commit() + return None + return lock diff --git a/backend/app/services/system_mail.py b/backend/app/services/system_mail.py index d439007..3dbf3cf 100644 --- a/backend/app/services/system_mail.py +++ b/backend/app/services/system_mail.py @@ -179,3 +179,32 @@ def notify_user_created(user, created_by_username): f'Deine Mini-Cloud' ) send_system_email(user.email, subject, body) + + +def notify_conflict_to_admin(conflict_user, conflict_file_name, conflict_copy_name, + folder_path, lock_user_name, lock_user_email, locked_since): + """Notify admin about a sync conflict (user edited a locked file).""" + from app.models.settings import AppSettings + + admin_email = AppSettings.get('system_email_from', '') + if not admin_email: + return + + subject = f'Mini-Cloud: Datei-Konflikt - {conflict_file_name}' + body = ( + f'Datei-Konflikt in der Mini-Cloud!\n\n' + f'Benutzer: {conflict_user.username}' + f'{" (" + conflict_user.email + ")" if conflict_user.email else ""}\n' + f'Hat bearbeitet: {conflict_file_name}\n' + f'Ordner: {folder_path}\n' + f'Konflikt-Kopie: {conflict_copy_name}\n\n' + f'Gesperrt von: {lock_user_name}' + f'{" (" + lock_user_email + ")" if lock_user_email else ""}\n' + f'Gesperrt seit: {locked_since}\n\n' + f'Ursache: {conflict_user.username} hat die Datei lokal bearbeitet ' + f'waehrend {lock_user_name} sie ausgecheckt hatte.\n\n' + f'Die Aenderungen von {conflict_user.username} wurden als ' + f'Konflikt-Kopie gespeichert und muessen manuell zusammengefuehrt werden.\n\n' + f'Deine Mini-Cloud' + ) + send_system_email(admin_email, subject, body) diff --git a/frontend/src/views/FilesView.vue b/frontend/src/views/FilesView.vue index 1585fa1..730d50a 100644 --- a/frontend/src/views/FilesView.vue +++ b/frontend/src/views/FilesView.vue @@ -58,6 +58,9 @@ {{ data.name }} + + {{ data.locked_by }} + @@ -223,6 +226,7 @@