1373 lines
46 KiB
Python
1373 lines
46 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.
|
|
|
|
Includes both files owned by the user (under 'tree') and files
|
|
shared WITH the user (as a virtual 'Geteilt mit mir' folder under
|
|
'shared'). The client merges both.
|
|
"""
|
|
user = request.current_user
|
|
|
|
def _entry(f):
|
|
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,
|
|
'modified_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
|
|
return entry
|
|
|
|
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:
|
|
e = _entry(f)
|
|
if f.is_folder:
|
|
e['children'] = _build_tree(f.id)
|
|
result.append(e)
|
|
return result
|
|
|
|
def _build_shared_children(parent_id):
|
|
files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\
|
|
.order_by(File.is_folder.desc(), File.name).all()
|
|
out = []
|
|
for f in files:
|
|
e = _entry(f)
|
|
if f.is_folder:
|
|
e['children'] = _build_shared_children(f.id)
|
|
out.append(e)
|
|
return out
|
|
|
|
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
|
|
shared_roots = []
|
|
seen = set()
|
|
for perm in shared_perms:
|
|
f = perm.file
|
|
if not f or f.is_trashed or f.id in seen:
|
|
continue
|
|
seen.add(f.id)
|
|
# Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt
|
|
# ist, ist dieses Item die Wurzel des Shares beim Empfaenger.
|
|
parent_shared = any(
|
|
p.file_id == f.parent_id for p in shared_perms
|
|
) if f.parent_id else False
|
|
if parent_shared:
|
|
continue
|
|
e = _entry(f)
|
|
owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None
|
|
if owner:
|
|
e['name'] = f'{f.name} (von {owner})'
|
|
if f.is_folder:
|
|
e['children'] = _build_shared_children(f.id)
|
|
shared_roots.append(e)
|
|
|
|
return jsonify({
|
|
'tree': _build_tree(None),
|
|
'shared': shared_roots,
|
|
}), 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
|