Files
ServerKit/backend/app/api/files.py
T
Juan DenisandClaude Opus 4.8 845df9959e Add S3 bucket browsing to the Files app (#14, #15)
- backend: StorageProviderService.s3_* (browse/read/write/upload/delete/
  presigned-get) + /api/v1/files/s3/* routes, reusing the configured backup
  bucket — no new credentials.
- frontend: an "S3 bucket" target in the File Manager. UI paths stay slash-rooted
  and map to S3 keys server-side, so breadcrumbs/history work unchanged; virtual
  folders from prefixes; presigned-URL download. Folders/rename/permissions are
  disabled and the local-only sidebar is hidden on S3. TargetPicker gained a
  backward-compatible extraOptions prop.
- docs: mark #14/#15 done (Phase 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:21:39 -04:00

490 lines
14 KiB
Python

"""File Manager API endpoints for browsing, editing, and managing files."""
from flask import Blueprint, request, jsonify, send_file
from flask_jwt_extended import jwt_required
from ..services.file_service import FileService
from ..services.storage_provider_service import StorageProviderService
import os
import tempfile
files_bp = Blueprint('files', __name__)
@files_bp.route('/browse', methods=['GET'])
@jwt_required()
def browse_directory():
"""List directory contents."""
path = request.args.get('path', '/home')
show_hidden = request.args.get('show_hidden', 'false').lower() == 'true'
result = FileService.list_directory(path, show_hidden=show_hidden)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@files_bp.route('/info', methods=['GET'])
@jwt_required()
def get_file_info():
"""Get information about a file or directory."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
if not FileService.is_path_allowed(path):
return jsonify({'error': 'Access denied'}), 403
info = FileService.get_file_info(path)
if info and 'error' not in info:
return jsonify({'success': True, 'file': info}), 200
error = info.get('error', 'File not found') if info else 'File not found'
return jsonify({'error': error}), 404
@files_bp.route('/read', methods=['GET'])
@jwt_required()
def read_file():
"""Read file contents."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = FileService.read_file(path)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/write', methods=['POST'])
@jwt_required()
def write_file():
"""Write content to a file."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
path = data.get('path')
content = data.get('content')
create_backup = data.get('create_backup', True)
if not path:
return jsonify({'error': 'Path is required'}), 400
if content is None:
return jsonify({'error': 'Content is required'}), 400
result = FileService.write_file(path, content, create_backup=create_backup)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/create', methods=['POST'])
@jwt_required()
def create_file():
"""Create a new file."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
path = data.get('path')
content = data.get('content', '')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = FileService.create_file(path, content)
if result.get('success'):
return jsonify(result), 201
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/mkdir', methods=['POST'])
@jwt_required()
def create_directory():
"""Create a new directory."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
path = data.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = FileService.create_directory(path)
if result.get('success'):
return jsonify(result), 201
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/delete', methods=['DELETE'])
@jwt_required()
def delete_path():
"""Delete a file or directory."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = FileService.delete(path)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/rename', methods=['POST'])
@jwt_required()
def rename_path():
"""Rename a file or directory."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
path = data.get('path')
new_name = data.get('new_name')
if not path or not new_name:
return jsonify({'error': 'Path and new_name are required'}), 400
result = FileService.rename(path, new_name)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/copy', methods=['POST'])
@jwt_required()
def copy_path():
"""Copy a file or directory."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
src = data.get('src')
dest = data.get('dest')
if not src or not dest:
return jsonify({'error': 'Source and destination paths are required'}), 400
result = FileService.copy(src, dest)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/move', methods=['POST'])
@jwt_required()
def move_path():
"""Move a file or directory."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
src = data.get('src')
dest = data.get('dest')
if not src or not dest:
return jsonify({'error': 'Source and destination paths are required'}), 400
result = FileService.move(src, dest)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/chmod', methods=['POST'])
@jwt_required()
def change_permissions():
"""Change file/directory permissions."""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
path = data.get('path')
mode = data.get('mode')
if not path or not mode:
return jsonify({'error': 'Path and mode are required'}), 400
result = FileService.change_permissions(path, mode)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/search', methods=['GET'])
@jwt_required()
def search_files():
"""Search for files matching a pattern."""
directory = request.args.get('directory', '/home')
pattern = request.args.get('pattern')
max_results = request.args.get('max_results', 100, type=int)
if not pattern:
return jsonify({'error': 'Search pattern is required'}), 400
result = FileService.search(directory, pattern, max_results=max_results)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/disk-usage', methods=['GET'])
@jwt_required()
def get_disk_usage():
"""Get disk usage for a path."""
path = request.args.get('path', '/')
result = FileService.get_disk_usage(path)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@files_bp.route('/disk-mounts', methods=['GET'])
@jwt_required()
def get_disk_mounts():
"""Get disk usage for all mount points."""
result = FileService.get_all_disk_mounts()
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@files_bp.route('/analyze', methods=['GET'])
@jwt_required()
def analyze_directory():
"""Analyze directory sizes."""
path = request.args.get('path', '/home')
depth = request.args.get('depth', 2, type=int)
limit = request.args.get('limit', 20, type=int)
result = FileService.analyze_directory_sizes(path, depth=depth, limit=limit)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/type-breakdown', methods=['GET'])
@jwt_required()
def get_type_breakdown():
"""Get file type breakdown for a directory."""
path = request.args.get('path', '/home')
max_depth = request.args.get('max_depth', 3, type=int)
result = FileService.get_file_type_breakdown(path, max_depth=max_depth)
if result.get('success'):
return jsonify(result), 200
status = 403 if 'denied' in result.get('error', '').lower() else 400
return jsonify(result), status
@files_bp.route('/download', methods=['GET'])
@jwt_required()
def download_file():
"""Download a file."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
if not FileService.is_path_allowed(path):
return jsonify({'error': 'Access denied'}), 403
if not os.path.exists(path):
return jsonify({'error': 'File not found'}), 404
if os.path.isdir(path):
return jsonify({'error': 'Cannot download directory'}), 400
try:
return send_file(
path,
as_attachment=True,
download_name=os.path.basename(path)
)
except Exception as e:
return jsonify({'error': str(e)}), 500
@files_bp.route('/upload', methods=['POST'])
@jwt_required()
def upload_file():
"""Upload a file."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
destination = request.form.get('destination')
if not destination:
return jsonify({'error': 'Destination path is required'}), 400
if not FileService.is_path_allowed(destination):
return jsonify({'error': 'Access denied'}), 403
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Check file size
file.seek(0, 2)
size = file.tell()
file.seek(0)
if size > FileService.MAX_UPLOAD_SIZE:
return jsonify({
'error': f'File too large. Maximum size is {FileService._format_size(FileService.MAX_UPLOAD_SIZE)}'
}), 400
try:
# Determine full path
if os.path.isdir(destination):
full_path = os.path.join(destination, file.filename)
else:
full_path = destination
if not FileService.is_path_allowed(full_path):
return jsonify({'error': 'Access denied'}), 403
# Ensure parent directory exists
parent = os.path.dirname(full_path)
if not os.path.exists(parent):
os.makedirs(parent)
# Save file
file.save(full_path)
return jsonify({
'success': True,
'path': full_path,
'size': size
}), 201
except PermissionError:
return jsonify({'error': 'Permission denied'}), 403
except Exception as e:
return jsonify({'error': str(e)}), 500
# ── S3 / object-storage browser (reuses the configured backup storage creds) ──
@files_bp.route('/s3/browse', methods=['GET'])
@jwt_required()
def s3_browse():
"""List a bucket prefix in the same entry shape as the local browser."""
path = request.args.get('path', '/')
result = StorageProviderService.s3_browse(path)
return jsonify(result), 200 if result.get('success') else 400
@files_bp.route('/s3/read', methods=['GET'])
@jwt_required()
def s3_read():
"""Read a text object for in-app editing."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = StorageProviderService.s3_read(path)
return jsonify(result), 200 if result.get('success') else 400
@files_bp.route('/s3/write', methods=['POST'])
@jwt_required()
def s3_write():
"""Write (create or overwrite) an object from text content."""
data = request.get_json() or {}
path = data.get('path')
content = data.get('content')
if not path:
return jsonify({'error': 'Path is required'}), 400
if content is None:
return jsonify({'error': 'Content is required'}), 400
result = StorageProviderService.s3_write(path, content)
return jsonify(result), 200 if result.get('success') else 400
@files_bp.route('/s3/delete', methods=['DELETE'])
@jwt_required()
def s3_delete():
"""Delete an object (or every object beneath a prefix)."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = StorageProviderService.s3_delete(path)
return jsonify(result), 200 if result.get('success') else 400
@files_bp.route('/s3/download-url', methods=['GET'])
@jwt_required()
def s3_download_url():
"""Return a short-lived presigned URL the browser can download directly."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
result = StorageProviderService.s3_presigned_get(path)
return jsonify(result), 200 if result.get('success') else 400
@files_bp.route('/s3/upload', methods=['POST'])
@jwt_required()
def s3_upload():
"""Upload a file into the bucket at the given prefix."""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
destination = request.form.get('destination', '/')
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
result = StorageProviderService.s3_upload(destination, file.filename, file.stream)
return jsonify(result), 201 if result.get('success') else 400