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

1031 lines
34 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
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."""
if file_obj.owner_id == user.id:
return True
perm = FilePermission.query.filter_by(
file_id=file_obj.id, user_id=user.id
).first()
if not perm:
return False
perm_levels = {'read': 0, 'write': 1, 'admin': 2}
return perm_levels.get(perm.permission, -1) >= perm_levels.get(permission, 0)
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)
# Own files in this folder (exclude trashed)
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)
files = query.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.parent_id.is_(None)
).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
result.append(d)
for f in shared:
d = f.to_dict()
d['shared'] = True
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()
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()
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()
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
return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True,
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
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()
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
# Soft-delete: move to trash
_trash_recursive(f)
db.session.commit()
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, err = _get_file_or_403(file_id, user, 'admin')
if err:
if not (f := db.session.get(File, file_id)) or f.owner_id != user.id:
return jsonify({'error': 'Zugriff verweigert'}), 403
perms = FilePermission.query.filter_by(file_id=file_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,
})
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 or f.owner_id != user.id:
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen setzen'}), 403
data = request.get_json()
target_user_id = data.get('user_id')
permission = data.get('permission', 'read')
if permission not in ('read', 'write', 'admin'):
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
from app.models.user import User
target = db.session.get(User, target_user_id)
if not target:
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
existing = FilePermission.query.filter_by(
file_id=file_id, user_id=target_user_id
).first()
is_new = not existing
if existing:
existing.permission = permission
else:
perm = FilePermission(file_id=file_id, user_id=target_user_id, permission=permission)
db.session.add(perm)
db.session.commit()
# 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 or f.owner_id != user.id:
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen entfernen'}), 403
perm = db.session.get(FilePermission, perm_id)
if not perm or perm.file_id != file_id:
return jsonify({'error': 'Berechtigung nicht gefunden'}), 404
db.session.delete(perm)
db.session.commit()
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, err = _get_file_or_403(file_id, user, 'read')
if err:
return err
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
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
# --- 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)\
.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,
}
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/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