minmal-file-cloud-email-pim.../backend/app/api/files.py

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