"""文件与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"]