from __future__ import annotations from server.chat import chat_bp import json, time from datetime import datetime from typing import Dict, Any, Optional from pathlib import Path import os from flask import Blueprint, jsonify, request, session, send_file from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge import secrets import mimetypes from config import MAX_UPLOAD_SIZE, OUTPUT_FORMATS from modules.personalization_manager import ( load_personalization_config, resolve_context_compression_settings, save_personalization_config, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, ) from modules.skills_manager import ( get_skills_catalog, infer_private_skills_dir, merge_enabled_skills, sync_workspace_skills, ) from modules.upload_security import UploadSecurityError from modules.host_sandbox_policy import load_policy, save_policy from modules.user_manager import UserWorkspace from core.web_terminal import WebTerminal from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker from server.security import rate_limited, prune_socket_tokens from server.utils_common import debug_log from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS from server.state import tool_approval_manager, user_question_manager from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from server.utils_common import sanitize_filename_preserve_unicode from modules.i18n import tr UPLOAD_FOLDER_NAME = ".astrion/user_upload" def _derive_filename_from_upload(uploaded_file) -> str: """当上传请求未提供文件名时,根据 MIME 类型或文件扩展名推断一个兜底文件名。""" mime_type = (uploaded_file.content_type or '').strip().lower() if mime_type: # 优先使用 mimetypes 库的 guess_extension,它通常比硬编码映射更全面 ext = mimetypes.guess_extension(mime_type, strict=False) if ext: return f"upload_{int(time.time())}{ext}" # 常见类型的兜底映射 type_map = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp', 'image/svg+xml': '.svg', 'video/mp4': '.mp4', 'video/quicktime': '.mov', 'video/x-matroska': '.mkv', 'video/avi': '.avi', 'video/webm': '.webm', 'application/pdf': '.pdf', 'text/plain': '.txt', 'text/markdown': '.md', } ext = type_map.get(mime_type) if ext: return f"upload_{int(time.time())}{ext}" return '' @chat_bp.route('/api/upload', methods=['POST']) @api_login_required @with_terminal @rate_limited("legacy_upload", 20, 300, scope="user") def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str): """处理前端文件上传请求""" policy = resolve_admin_policy(get_current_user_record()) if policy.get("ui_blocks", {}).get("block_upload"): return jsonify({ "success": False, "error": tr("chat_files.upload_admin_disabled"), "message": tr("chat_files.upload_admin_disabled_message") }), 403 if 'file' not in request.files: return jsonify({ "success": False, "error": tr("chat_files.file_not_found_in_request"), "message": tr("chat_files.missing_file_field") }), 400 uploaded_file = request.files['file'] original_name = (request.form.get('filename') or '').strip() if not uploaded_file: return jsonify({ "success": False, "error": tr("chat_files.file_not_found_in_request"), "message": tr("chat_files.missing_file_content") }), 400 raw_name = original_name or uploaded_file.filename or '' if not raw_name.strip(): # 文件名缺失时,尝试根据 MIME 类型或文件内容推断一个兜底文件名 raw_name = _derive_filename_from_upload(uploaded_file) if not raw_name.strip(): return jsonify({ "success": False, "error": tr("chat_files.empty_filename"), "message": tr("chat_files.choose_file") }), 400 filename = sanitize_filename_preserve_unicode(raw_name) if not filename: filename = secure_filename(raw_name) if not filename: return jsonify({ "success": False, "error": tr("chat_files.invalid_filename"), "message": tr("chat_files.unsupported_filename_chars") }), 400 file_manager = getattr(terminal, 'file_manager', None) if file_manager is None: return jsonify({ "success": False, "error": tr("chat_files.file_manager_uninitialized") }), 500 target_folder_relative = UPLOAD_FOLDER_NAME valid_folder, folder_error, folder_path = file_manager._validate_path(target_folder_relative) if not valid_folder: return jsonify({ "success": False, "error": folder_error }), 400 try: folder_path.mkdir(parents=True, exist_ok=True) except Exception as exc: return jsonify({ "success": False, "error": tr("chat_files.create_upload_dir_failed", error=exc) }), 500 target_relative = str(Path(target_folder_relative) / filename) valid_file, file_error, target_full_path = file_manager._validate_path(target_relative) if not valid_file: return jsonify({ "success": False, "error": file_error }), 400 final_path = target_full_path if final_path.exists(): stem = final_path.stem suffix = final_path.suffix counter = 1 while final_path.exists(): candidate_name = f"{stem}_{counter}{suffix}" target_relative = str(Path(target_folder_relative) / candidate_name) valid_file, file_error, candidate_path = file_manager._validate_path(target_relative) if not valid_file: return jsonify({ "success": False, "error": file_error }), 400 final_path = candidate_path counter += 1 try: relative_path = str(final_path.relative_to(workspace.project_path)) except Exception as exc: return jsonify({ "success": False, "error": tr("chat_files.path_resolve_failed", error=exc) }), 400 guard = get_upload_guard(workspace) try: result = guard.process_upload( uploaded_file, final_path, username=username, source="legacy_upload", 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": tr("chat_files.save_failed", error=exc) }), 500 metadata = result.get("metadata", {}) print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}") return jsonify({ "success": True, "path": relative_path, "filename": final_path.name, "folder": target_folder_relative, "scan": metadata.get("scan"), "sha256": metadata.get("sha256"), "size": metadata.get("size"), }) @chat_bp.errorhandler(RequestEntityTooLarge) def handle_file_too_large(error): """全局捕获上传超大小""" size_mb = MAX_UPLOAD_SIZE / (1024 * 1024) return jsonify({ "success": False, "error": tr("chat_files.file_too_large"), "message": tr("chat_files.file_too_large_message", size_mb=size_mb) }), 413 @chat_bp.route('/api/download/file') @api_login_required @with_terminal def download_file_api(terminal: WebTerminal, workspace: UserWorkspace, username: str): """下载单个文件""" path = (request.args.get('path') or '').strip() if not path: return jsonify({"success": False, "error": tr("chat_files.missing_path_param")}), 400 valid, error, full_path = terminal.file_manager._validate_path(path) if not valid or full_path is None: return jsonify({"success": False, "error": error or tr("chat_files.path_validation_failed")}), 400 if not full_path.exists() or not full_path.is_file(): return jsonify({"success": False, "error": tr("chat_files.file_not_found")}), 404 return send_file( full_path, as_attachment=True, download_name=full_path.name ) # 文件夹打包下载功能已彻底移除(2026-09-02 安全审计): # 原 /api/download/folder 端点在打包时不对 rglob 子项做符号链接/越界校验, # 攻击者在容器工作区内创建指向宿主绝对路径的文件型符号链接即可经 zip 泄露宿主任意文件 # (含 settings.json / .env / 其他用户数据)。前端已无调用,功能直接下线。 # 文件预览允许 inline 展示的 MIME 前缀白名单 # 任何不在此白名单内的类型一律退化为 application/octet-stream(浏览器会触发下载) _FILE_CONTENT_INLINE_MIME_PREFIXES = ( 'text/', 'image/', 'application/pdf', 'application/json', 'application/javascript', 'application/xml', ) # 允许 inline 预览的文本类扩展名(mimetypes 未识别时兑底) _FILE_CONTENT_INLINE_TEXT_EXTS = { '.txt', '.md', '.markdown', '.csv', '.json', '.json5', '.yaml', '.yml', '.js', '.ts', '.jsx', '.tsx', '.vue', '.py', '.rb', '.go', '.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.swift', '.kt', '.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', '.html', '.htm', '.css', '.scss', '.sass', '.less', '.xml', '.svg', '.toml', '.ini', '.cfg', '.conf', '.log', '.sql', } def _resolve_inline_mime(full_path: Path) -> str: """猜测 inline 预览用的 MIME 类型,不在白名单内的退化为 octet-stream。""" suffix = full_path.suffix.lower() # SVG 虽为 image/*,但可携带脚本,inline 渲染即存储型 XSS——与 HTML 同策略强制下载 if suffix == '.svg': return 'application/octet-stream' guessed, _ = mimetypes.guess_type(str(full_path)) if guessed: if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES): return guessed return 'application/octet-stream' # mimetypes 未识别,按扩展名兑底 if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS: return 'text/plain; charset=utf-8' if suffix == '.pdf': return 'application/pdf' return 'application/octet-stream' @chat_bp.route('/api/file/content') @api_login_required @with_terminal def file_content_api(terminal: WebTerminal, workspace: UserWorkspace, username: str): """以 inline 方式返回文件内容,用于前端预览(区别于 /api/download/file 的 attachment)。 路径校验复用 _validate_path,与下载接口同等安全级别。 非白名单 MIME 退化为 octet-stream,避免浏览器误执行 HTML/JS。""" path = (request.args.get('path') or '').strip() if not path: return jsonify({"success": False, "error": tr("chat_files.missing_path_param")}), 400 valid, error, full_path = terminal.file_manager._validate_path(path) if not valid or full_path is None: return jsonify({"success": False, "error": error or tr("chat_files.path_validation_failed")}), 400 if not full_path.exists() or not full_path.is_file(): return jsonify({"success": False, "error": tr("chat_files.file_not_found")}), 404 mime_type = _resolve_inline_mime(full_path) # 永远不要让浏览器 inline 执行 HTML/JS(防 XSS); # HTML 文件强制走下载,不 inline 预览 if mime_type.startswith('text/html'): mime_type = 'application/octet-stream' return send_file( full_path, mimetype=mime_type, as_attachment=False )