agent-Specialization/server/files.py

457 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""文件与GUI文件管理相关路由。"""
from __future__ import annotations
import os
import re
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
from modules.upload_security import UploadSecurityError
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record
from .security import rate_limited
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response
from .utils_common import debug_log
files_bp = Blueprint("files", __name__)
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分"""
import re
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
cleaned = cleaned.replace("\\", "/").split("/")[-1]
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
return cleaned[:255]
@files_bp.route('/api/files')
@api_login_required
@with_terminal
def get_files(terminal, workspace, username):
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
structure = terminal.context_manager.get_project_structure()
return jsonify(structure)
def _format_entry(entry) -> Dict[str, Any]:
return {
"name": entry.name,
"path": entry.path,
"type": entry.type,
"size": entry.size,
"modified_at": entry.modified_at,
"extension": entry.extension,
"is_editable": entry.is_editable,
}
@files_bp.route('/api/gui/files/entries', methods=['GET'])
@api_login_required
@with_terminal
def gui_list_entries(terminal, workspace, username):
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
relative_path = request.args.get('path') or ""
manager = get_gui_manager(workspace)
try:
resolved_path, entries = manager.list_directory(relative_path)
breadcrumb = manager.breadcrumb(resolved_path)
return jsonify({
"success": True,
"data": {
"path": resolved_path,
"breadcrumb": breadcrumb,
"items": [_format_entry(entry) for entry in entries]
}
})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/create', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_create", 30, 60, scope="user")
def gui_create_entry(terminal, workspace, username):
payload = request.get_json() or {}
parent = payload.get('path') or ""
name = payload.get('name') or ""
entry_type = payload.get('type') or "file"
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.create_entry(parent, name, entry_type)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/delete', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_delete", 30, 60, scope="user")
def gui_delete_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.delete_entries(paths)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/rename', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_rename", 30, 60, scope="user")
def gui_rename_entry(terminal, workspace, username):
payload = request.get_json() or {}
path = payload.get('path')
new_name = payload.get('new_name')
if not path or not new_name:
return jsonify({"success": False, "error": "缺少 path 或 new_name"}), 400
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.rename_entry(path, new_name)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/copy', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_copy", 40, 120, scope="user")
def gui_copy_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.copy_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/move', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_move", 40, 120, scope="user")
def gui_move_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
manager = get_gui_manager(workspace)
try:
result = manager.move_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_upload", 10, 300, scope="user")
def gui_upload_entry(terminal, workspace, username):
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_upload"):
return jsonify({"success": False, "error": "文件上传已被管理员禁用"}), 403
if 'file' not in request.files:
return jsonify({"success": False, "error": "未找到文件"}), 400
file_obj = request.files['file']
if not file_obj or not file_obj.filename:
return jsonify({"success": False, "error": "文件名为空"}), 400
current_dir = request.form.get('path') or ""
raw_name = request.form.get('filename') or file_obj.filename
filename = sanitize_filename_preserve_unicode(raw_name) or secure_filename(raw_name)
if not filename:
return jsonify({"success": False, "error": "非法文件名"}), 400
manager = get_gui_manager(workspace)
try:
target_path = manager.prepare_upload(current_dir, filename)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
try:
relative_path = manager._to_relative(target_path)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
guard = get_upload_guard(workspace)
try:
result = guard.process_upload(
file_obj,
target_path,
username=username,
source="web_gui",
original_name=raw_name,
relative_path=relative_path,
)
except UploadSecurityError as exc:
return build_upload_error_response(exc)
except Exception as exc:
return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500
metadata = result.get("metadata", {})
return jsonify({
"success": True,
"path": relative_path,
"filename": target_path.name,
"scan": metadata.get("scan"),
"sha256": metadata.get("sha256"),
"size": metadata.get("size"),
})
@files_bp.route('/api/gui/files/download', methods=['GET'])
@api_login_required
@with_terminal
def gui_download_entry(terminal, workspace, username):
path = request.args.get('path')
if not path:
return jsonify({"success": False, "error": "缺少 path"}), 400
manager = get_gui_manager(workspace)
try:
target = manager.prepare_download(path)
if target.is_dir():
memory_file = BytesIO()
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(target):
for file in files:
full_path = Path(root) / file
arcname = manager._to_relative(full_path)
zf.write(full_path, arcname=arcname)
memory_file.seek(0)
download_name = f"{target.name}.zip"
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
return send_file(target, as_attachment=True, download_name=target.name)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/download/batch', methods=['POST'])
@api_login_required
@with_terminal
def gui_download_batch(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
if not paths:
return jsonify({"success": False, "error": "缺少待下载的路径"}), 400
manager = get_gui_manager(workspace)
try:
memory_file = BytesIO()
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for rel in paths:
target = manager.prepare_download(rel)
arc_base = rel.strip('/') or target.name
if target.is_dir():
for root, _, files in os.walk(target):
for file in files:
full_path = Path(root) / file
relative_sub = full_path.relative_to(target)
arcname = Path(arc_base) / relative_sub
zf.write(full_path, arcname=str(arcname))
else:
zf.write(target, arcname=arc_base)
memory_file.seek(0)
download_name = f"selected_{len(paths)}.zip"
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/text', methods=['GET', 'POST'])
@api_login_required
@with_terminal
def gui_text_entry(terminal, workspace, username):
manager = get_gui_manager(workspace)
if request.method == 'GET':
path = request.args.get('path')
if not path:
return jsonify({"success": False, "error": "缺少 path"}), 400
try:
content, modified = manager.read_text(path)
return jsonify({"success": True, "path": path, "content": content, "modified_at": modified})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
payload = request.get_json() or {}
path = payload.get('path')
content = payload.get('content')
if path is None or content is None:
return jsonify({"success": False, "error": "缺少 path 或 content"}), 400
try:
result = manager.write_text(path, content)
return jsonify({"success": True, "data": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
def _score_file_match(path: str, query: str) -> Optional[Tuple[int, ...]]:
"""为 @文件 搜索计算匹配分数分数越低越靠前None 表示不匹配。"""
path_lower = path.lower()
query_lower = query.lower()
if path_lower.startswith(query_lower):
return (0, len(path))
query_parts = query_lower.split('/')
path_parts = path_lower.split('/')
if len(query_parts) > 1 and len(path_parts) >= len(query_parts):
matched = True
for i, q in enumerate(query_parts):
p = path_parts[i]
if not p.startswith(q) and q not in p:
matched = False
break
if matched:
return (1, len(path))
name = path_parts[-1] if path_parts else path_lower
if name.startswith(query_lower):
return (2, len(path))
if query_lower in name:
return (3, len(path))
if query_lower in path_lower:
return (4, len(path))
return None
def _scan_project_entries(project_path: Path, max_depth: int = 6) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""扫描项目目录,返回文件和文件夹列表(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
for root, dirs, filenames in os.walk(project_path):
rel_root = Path(root).relative_to(project_path)
level = len(rel_root.parts) if str(rel_root) != '.' else 0
if level > max_depth:
del dirs[:]
continue
for d in list(dirs):
dir_path = rel_root / d if str(rel_root) != '.' else Path(d)
folders.append({
"name": d,
"path": str(dir_path).replace('\\', '/'),
"type": "directory"
})
for f in filenames:
file_path = rel_root / f if str(rel_root) != '.' else Path(f)
ext = Path(f).suffix.lower()
files.append({
"name": f,
"path": str(file_path).replace('\\', '/'),
"type": "file",
"extension": ext
})
return files, folders
def _scan_root_entries(project_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""只扫描项目根目录的直接子项(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
if not project_path.exists() or not project_path.is_dir():
return files, folders
for entry in sorted(project_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
if entry.is_dir():
folders.append({
"name": entry.name,
"path": entry.name,
"type": "directory"
})
elif entry.is_file():
files.append({
"name": entry.name,
"path": entry.name,
"type": "file",
"extension": entry.suffix.lower()
})
return files, folders
@files_bp.route('/api/project/files/search', methods=['GET'])
@api_login_required
@with_terminal
def search_project_files(terminal, workspace, username):
"""为 @文件 功能提供项目内文件/文件夹搜索(包含隐藏目录)。"""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
query = str(request.args.get('q') or '').strip()
try:
project_path = Path(getattr(workspace, 'project_path', '') or '').expanduser().resolve()
if not project_path.exists():
return jsonify({"success": False, "error": "项目路径不存在"}), 400
except Exception as exc:
return jsonify({"success": False, "error": f"项目路径无效: {exc}"}), 400
try:
files, folders = _scan_project_entries(project_path, max_depth=6)
except Exception as exc:
return jsonify({"success": False, "error": f"扫描项目失败: {exc}"}), 500
if not query:
root_files, root_folders = _scan_root_entries(project_path)
root_items = root_folders + root_files
return jsonify({
"success": True,
"data": {
"items": root_items[:50],
"total": len(root_items)
}
})
scored: List[Tuple[Tuple[int, ...], Dict[str, Any]]] = []
for entry in files + folders:
score = _score_file_match(entry["path"], query)
if score is not None:
scored.append((score, entry))
scored.sort(key=lambda x: (x[0], x[1]["path"].lower()))
limit = max(10, min(100, int(request.args.get('limit') or 50)))
return jsonify({
"success": True,
"data": {
"items": [entry for _, entry in scored[:limit]],
"total": len(scored)
}
})
__all__ = ["files_bp"]