361 lines
13 KiB
Python
361 lines
13 KiB
Python
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
|
||
from io import BytesIO
|
||
import zipfile
|
||
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 THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
||
from modules.personalization_manager import (
|
||
load_personalization_config,
|
||
resolve_context_compression_settings,
|
||
save_personalization_config,
|
||
THINKING_INTERVAL_MIN,
|
||
THINKING_INTERVAL_MAX,
|
||
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, THINKING_FAILURE_KEYWORDS, 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.files import sanitize_filename_preserve_unicode
|
||
|
||
UPLOAD_FOLDER_NAME = ".agents/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": "文件上传已被管理员禁用",
|
||
"message": "被管理员禁用上传"
|
||
}), 403
|
||
if 'file' not in request.files:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "未找到文件",
|
||
"message": "请求中缺少文件字段"
|
||
}), 400
|
||
|
||
uploaded_file = request.files['file']
|
||
original_name = (request.form.get('filename') or '').strip()
|
||
|
||
if not uploaded_file:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "未找到文件",
|
||
"message": "请求中缺少文件内容"
|
||
}), 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": "文件名为空",
|
||
"message": "请选择要上传的文件"
|
||
}), 400
|
||
|
||
filename = sanitize_filename_preserve_unicode(raw_name)
|
||
if not filename:
|
||
filename = secure_filename(raw_name)
|
||
if not filename:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "非法文件名",
|
||
"message": "文件名包含不支持的字符"
|
||
}), 400
|
||
|
||
file_manager = getattr(terminal, 'file_manager', None)
|
||
if file_manager is None:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "文件管理器未初始化"
|
||
}), 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": f"创建上传目录失败: {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": f"路径解析失败: {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": f"保存文件失败: {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": "文件过大",
|
||
"message": f"单个文件大小不可超过 {size_mb:.1f} 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": "缺少路径参数"}), 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 "路径校验失败"}), 400
|
||
if not full_path.exists() or not full_path.is_file():
|
||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||
|
||
return send_file(
|
||
full_path,
|
||
as_attachment=True,
|
||
download_name=full_path.name
|
||
)
|
||
|
||
@chat_bp.route('/api/download/folder')
|
||
@api_login_required
|
||
@with_terminal
|
||
def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""打包并下载文件夹"""
|
||
path = (request.args.get('path') or '').strip()
|
||
if not path:
|
||
return jsonify({"success": False, "error": "缺少路径参数"}), 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 "路径校验失败"}), 400
|
||
if not full_path.exists() or not full_path.is_dir():
|
||
return jsonify({"success": False, "error": "文件夹不存在"}), 404
|
||
|
||
buffer = BytesIO()
|
||
folder_name = Path(path).name or full_path.name or "archive"
|
||
|
||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_buffer:
|
||
# 确保目录本身被包含
|
||
zip_buffer.write(full_path, arcname=folder_name + '/')
|
||
|
||
for item in full_path.rglob('*'):
|
||
relative_name = Path(folder_name) / item.relative_to(full_path)
|
||
if item.is_dir():
|
||
zip_buffer.write(item, arcname=str(relative_name) + '/')
|
||
else:
|
||
zip_buffer.write(item, arcname=str(relative_name))
|
||
|
||
buffer.seek(0)
|
||
return send_file(
|
||
buffer,
|
||
mimetype='application/zip',
|
||
as_attachment=True,
|
||
download_name=f"{folder_name}.zip"
|
||
)
|
||
|
||
|
||
# 文件预览允许 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。"""
|
||
guessed, _ = mimetypes.guess_type(str(full_path))
|
||
if guessed:
|
||
if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES):
|
||
return guessed
|
||
return 'application/octet-stream'
|
||
# mimetypes 未识别,按扩展名兑底
|
||
suffix = full_path.suffix.lower()
|
||
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": "缺少路径参数"}), 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 "路径校验失败"}), 400
|
||
if not full_path.exists() or not full_path.is_file():
|
||
return jsonify({"success": False, "error": "文件不存在"}), 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
|
||
)
|