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:
parent
33156f9431
commit
748537b9f5
|
|
@ -15,6 +15,7 @@ from app.api import api_bp
|
||||||
from app.api.auth import token_required
|
from app.api.auth import token_required
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models.file import File, FilePermission, ShareLink
|
from app.models.file import File, FilePermission, ShareLink
|
||||||
|
from app.models.file_lock import FileLock
|
||||||
|
|
||||||
|
|
||||||
def _user_upload_dir(user_id):
|
def _user_upload_dir(user_id):
|
||||||
|
|
@ -82,6 +83,11 @@ def list_files():
|
||||||
d = f.to_dict()
|
d = f.to_dict()
|
||||||
d['has_shares'] = ShareLink.query.filter_by(file_id=f.id).count() > 0
|
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
|
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)
|
result.append(d)
|
||||||
for f in shared:
|
for f in shared:
|
||||||
d = f.to_dict()
|
d = f.to_dict()
|
||||||
|
|
@ -975,6 +981,104 @@ def delete_share_link(token):
|
||||||
return jsonify({'message': 'Link geloescht'}), 200
|
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 ---
|
# --- Sync API ---
|
||||||
|
|
||||||
@api_bp.route('/sync/tree', methods=['GET'])
|
@api_bp.route('/sync/tree', methods=['GET'])
|
||||||
|
|
@ -996,6 +1100,10 @@ def sync_tree():
|
||||||
'checksum': f.checksum,
|
'checksum': f.checksum,
|
||||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
'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:
|
if f.is_folder:
|
||||||
entry['children'] = _build_tree(f.id)
|
entry['children'] = _build_tree(f.id)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from app.models.email_account import EmailAccount
|
||||||
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
||||||
from app.models.settings import AppSettings
|
from app.models.settings import AppSettings
|
||||||
from app.models.backup_target import BackupTarget
|
from app.models.backup_target import BackupTarget
|
||||||
|
from app.models.file_lock import FileLock
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -179,3 +179,32 @@ def notify_user_created(user, created_by_username):
|
||||||
f'Deine Mini-Cloud'
|
f'Deine Mini-Cloud'
|
||||||
)
|
)
|
||||||
send_system_email(user.email, subject, body)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@
|
||||||
<i :class="fileIcon(data)" class="file-icon"></i>
|
<i :class="fileIcon(data)" class="file-icon"></i>
|
||||||
<span>{{ data.name }}</span>
|
<span>{{ data.name }}</span>
|
||||||
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
|
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
|
||||||
|
<span v-if="data.locked" class="lock-badge" :title="'Ausgecheckt von ' + data.locked_by + ' seit ' + formatDate(data.locked_at)">
|
||||||
|
<i class="pi pi-lock"></i> {{ data.locked_by }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
@ -223,6 +226,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useFilesStore } from '../stores/files'
|
import { useFilesStore } from '../stores/files'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
|
@ -238,6 +242,7 @@ import ProgressBar from 'primevue/progressbar'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const filesStore = useFilesStore()
|
const filesStore = useFilesStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
|
@ -298,6 +303,15 @@ function handleDoubleClick(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPreview(data) {
|
function openPreview(data) {
|
||||||
|
if (data.locked && data.locked_by !== auth.user?.username) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Datei gesperrt',
|
||||||
|
detail: `${data.name} wird von ${data.locked_by} bearbeitet. Oeffnen nicht moeglich.`,
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp|odt|ods|odp|rtf)$/i
|
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp|odt|ods|odp|rtf)$/i
|
||||||
if (previewable.test(data.name)) {
|
if (previewable.test(data.name)) {
|
||||||
router.push(`/preview/${data.id}`)
|
router.push(`/preview/${data.id}`)
|
||||||
|
|
@ -706,6 +720,12 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
|
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
|
||||||
.shared-tag { font-size: 0.7rem; }
|
.shared-tag { font-size: 0.7rem; }
|
||||||
|
.lock-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||||
|
font-size: 0.7rem; color: var(--p-orange-600); background: var(--p-orange-50);
|
||||||
|
padding: 0.125rem 0.375rem; border-radius: 4px; margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.lock-badge i { font-size: 0.65rem; }
|
||||||
.row-actions { display: flex; gap: 0; }
|
.row-actions { display: flex; gap: 0; }
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue