9b135e42b7
Backend: set_permission und remove_permission feuern jetzt ein SSE-Event vom Typ 'permission' an Target-User + Owner + weitere Share-Empfaenger. Damit aktualisieren sich die Dateilisten aller Beteiligten in Echtzeit - auch beim Betroffenen, der gerade seinen Zugriff verliert. Frontend: FilesView wrapped loadFiles in safeLoadCurrentFolder(). Bei 403/404 erscheint ein Toast "Dieser Ordner wurde geloescht oder die Freigabe wurde entfernt" und nach 600ms wird zurueck zum Root navigiert. Greift beim Direktaufruf, beim Ordnerwechsel und bei durch SSE ausgeloesten Auto-Reloads. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1326 lines
45 KiB
Python
1326 lines
45 KiB
Python
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/<int:file_id>/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/<int:file_id>', 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/<int:file_id>', 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/<int:file_id>/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/<int:file_id>', 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/<int:file_id>/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/<int:file_id>/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/<int:file_id>/permissions/<int:perm_id>', 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/<int:file_id>/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/<int:file_id>/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/<token>/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/<token>/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/<token>/files/<int:file_id>/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/<token>/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/<token>/files/<int:file_id>', 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/<token>/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/<token>/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/<token>/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/<token>', 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/<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()
|
|
notify_file_change(f.owner_id, f.id, 'locked',
|
|
shared_with=_share_recipients(f))
|
|
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()
|
|
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/<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'])
|
|
@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, is_trashed=False)\
|
|
.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,
|
|
}
|
|
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)
|
|
return result
|
|
|
|
return jsonify({'tree': _build_tree(None)}), 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
|