feat: File Locking System (Ein-/Auschecken) + Konflikt-Email

Backend - FileLock Model + API:
- POST /files/<id>/lock - Datei auschecken (sperren)
- POST /files/<id>/unlock - Datei einchecken (entsperren)
- POST /files/<id>/heartbeat - "Datei noch offen" (alle 60s)
- GET /files/<id>/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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 23:20:55 +02:00
parent 33156f9431
commit 748537b9f5
5 changed files with 216 additions and 0 deletions
+108
View File
@@ -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/<int:file_id>/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/<int:file_id>/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/<int:file_id>/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/<int:file_id>/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)
+1
View File
@@ -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',
+58
View File
@@ -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
+29
View File
@@ -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)