feat(security): 对于图谱文件上传,添加从MinIO URL下载文件的临时文件上下文管理器

重构文件上传服务以使用新的MinIO上下文管理器
移除本地文件路径支持,仅允许MinIO URL
This commit is contained in:
Wenjie Zhang 2026-01-02 22:52:33 +08:00
parent 2524b60d9a
commit dd5dc53a64
3 changed files with 111 additions and 55 deletions

View File

@ -2,11 +2,12 @@ import traceback
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from src.storage.db.models import User
from server.utils.auth_middleware import get_admin_user from server.utils.auth_middleware import get_admin_user
from src import graph_base, knowledge_base from src import graph_base, knowledge_base
from src.knowledge.adapters.factory import GraphAdapterFactory
from src.knowledge.adapters.base import GraphAdapter from src.knowledge.adapters.base import GraphAdapter
from src.knowledge.adapters.factory import GraphAdapterFactory
from src.storage.db.models import User
from src.storage.minio.client import StorageError
from src.utils.logging_config import logger from src.utils.logging_config import logger
graph = APIRouter(prefix="/graph", tags=["graph"]) graph = APIRouter(prefix="/graph", tags=["graph"])
@ -272,21 +273,17 @@ async def add_neo4j_entities(
batch_size: int | None = Body(None), batch_size: int | None = Body(None),
current_user: User = Depends(get_admin_user), current_user: User = Depends(get_admin_user),
): ):
"""通过JSONL文件添加图谱实体到Neo4j""" """通过JSONL文件添加图谱实体到Neo4j(只接受 MinIO URL"""
try: try:
# 验证文件路径 # 服务层会验证 URL 并从 MinIO 下载文件
if not file_path or not isinstance(file_path, str):
return {"success": False, "message": "文件路径不能为空", "status": "failed"}
file_path = file_path.strip()
if not file_path:
return {"success": False, "message": "文件路径不能为空", "status": "failed"}
if not file_path.endswith(".jsonl"):
return {"success": False, "message": "文件格式错误请上传jsonl文件", "status": "failed"}
await graph_base.jsonl_file_add_entity(file_path, kgdb_name, embed_model_name, batch_size) await graph_base.jsonl_file_add_entity(file_path, kgdb_name, embed_model_name, batch_size)
return {"success": True, "message": "实体添加成功", "status": "success"} return {"success": True, "message": "实体添加成功", "status": "success"}
except StorageError as e:
# MinIO 验证或下载错误
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
# 本地路径拒绝错误
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"添加实体失败: {e}, {traceback.format_exc()}") logger.error(f"添加实体失败: {e}, {traceback.format_exc()}")
return {"success": False, "message": f"添加实体失败: {e}", "status": "failed"} return {"success": False, "message": f"添加实体失败: {e}", "status": "failed"}

View File

@ -1,6 +1,5 @@
import json import json
import os import os
import tempfile
import traceback import traceback
import warnings import warnings
from urllib.parse import urlparse from urllib.parse import urlparse
@ -93,61 +92,38 @@ class UploadGraphService:
# 检测 file_path 是否是 URL # 检测 file_path 是否是 URL
parsed_url = urlparse(file_path) parsed_url = urlparse(file_path)
temp_file_path = None
try: try:
if parsed_url.scheme in ("http", "https"): # 如果是 URL if parsed_url.scheme in ("http", "https"): # 如果是 URL
logger.info(f"检测到 URL正在从 MinIO 下载文件: {file_path}") logger.info(f"检测到 URL正在从 MinIO 下载文件: {file_path}")
# 从 URL 解析 bucket_name 和 object_name # 使用 MinIO 客户端下载到临时文件(使用上下文管理器)
# URL 格式: http://host:port/bucket_name/object_name
path_parts = parsed_url.path.lstrip("/").split("/", 1)
if len(path_parts) < 2:
raise ValueError(f"无法解析 MinIO URL: {file_path}")
bucket_name = path_parts[0]
object_name = path_parts[1]
# 从 MinIO 下载文件
minio_client = get_minio_client() minio_client = get_minio_client()
file_data = await minio_client.adownload_file(bucket_name, object_name) async with minio_client.temp_file_from_url(
file_path, allowed_extensions=[".jsonl"]
) as actual_file_path:
def read_triples(file_path):
with open(file_path, encoding="utf-8") as file:
for line in file:
if line.strip():
yield json.loads(line.strip())
# 创建临时文件保存下载的内容 triples = list(read_triples(actual_file_path))
with tempfile.NamedTemporaryFile( await self.txt_add_vector_entity(triples, kgdb_name, embed_model_name, batch_size)
mode="w", suffix=".jsonl", delete=False, encoding="utf-8" # 退出 with 块后,临时文件自动清理
) as temp_file:
temp_file.write(file_data.decode("utf-8"))
temp_file_path = temp_file.name
logger.info(f"文件已下载到临时路径: {temp_file_path}")
actual_file_path = temp_file_path
else: else:
# 本地文件路径 # 本地文件路径 - 拒绝不安全的本地路径
actual_file_path = file_path raise ValueError(
"不支持本地文件路径,只允许 MinIO URL。请先通过文件上传接口上传文件。"
def read_triples(file_path): )
with open(file_path, encoding="utf-8") as file:
for line in file:
if line.strip():
yield json.loads(line.strip())
triples = list(read_triples(actual_file_path))
await self.txt_add_vector_entity(triples, kgdb_name, embed_model_name, batch_size)
except Exception as e: except Exception as e:
logger.error(f"处理文件失败: {e}") logger.error(f"处理文件失败: {e}")
raise raise
finally: finally:
# 清理临时文件 self.connection.status = "open"
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
logger.info(f"已删除临时文件: {temp_file_path}")
except Exception as e:
logger.warning(f"删除临时文件失败: {e}")
self.connection.status = "open"
# 更新并保存图数据库信息 # 更新并保存图数据库信息
self.save_graph_info() self.save_graph_info()
return kgdb_name return kgdb_name

View File

@ -6,6 +6,7 @@ MinIO 存储客户端
import asyncio import asyncio
import json import json
import os import os
from contextlib import asynccontextmanager
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
@ -275,6 +276,88 @@ class MinIOClient:
logger.warning(f"设置存储桶 '{bucket_name}' 公共读取策略失败: {e}") logger.warning(f"设置存储桶 '{bucket_name}' 公共读取策略失败: {e}")
raise StorageError(f"无法设置存储桶公共访问策略: {e}") raise StorageError(f"无法设置存储桶公共访问策略: {e}")
@asynccontextmanager
async def temp_file_from_url(
self,
url: str,
allowed_extensions: list[str] | None = None,
):
"""
异步上下文管理器 MinIO URL 下载文件到临时文件使用后自动清理
Args:
url: MinIO 文件 URL
allowed_extensions: 允许的文件扩展名列表可选
Yields:
str: 临时文件路径
Raises:
StorageError: 如果 URL 无效或下载失败
"""
import tempfile
from urllib.parse import urlparse
# 验证 URL
if not url or not isinstance(url, str):
raise StorageError("URL 不能为空")
url = url.strip()
if not url.startswith(("http://", "https://")):
raise StorageError("无效的 MinIO URL只允许 http/https")
parsed = urlparse(url)
# 验证主机
endpoint_host = self.endpoint.split("://")[-1].split(":")[0]
url_host = parsed.netloc.split(":")[0]
if endpoint_host != url_host and url_host != os.environ.get("HOST_IP", "localhost"):
raise StorageError(f"不允许的外部 URL: {url_host}")
# 检查路径遍历
if ".." in url or "\\" in url:
raise StorageError("URL 包含路径遍历字符")
# 验证扩展名
if allowed_extensions and not any(url.endswith(ext) for ext in allowed_extensions):
raise StorageError(f"文件扩展名不符合要求,允许: {', '.join(allowed_extensions)}")
# 解析 bucket 和 object name
path_parts = parsed.path.lstrip("/").split("/", 1)
if len(path_parts) != 2:
raise StorageError("无法解析 MinIO URL")
bucket_name, object_name = path_parts
# 下载文件
file_data = await self.adownload_file(bucket_name, object_name)
logger.info(f"成功从 MinIO 下载文件: {object_name} ({len(file_data)} bytes)")
# 创建临时文件
if allowed_extensions:
suffix = next((ext for ext in allowed_extensions if url.endswith(ext)), ".tmp")
else:
suffix = f".{object_name.split('.')[-1]}"
temp_path = None
try:
with tempfile.NamedTemporaryFile(mode="wb", suffix=suffix, delete=False) as temp_file:
temp_file.write(file_data)
temp_path = temp_file.name
logger.info(f"文件已下载到临时路径: {temp_path}")
yield temp_path
finally:
if temp_path and os.path.exists(temp_path):
try:
os.unlink(temp_path)
logger.info(f"已删除临时文件: {temp_path}")
except Exception as e:
logger.warning(f"删除临时文件失败: {e}")
# 全局客户端实例 # 全局客户端实例
_default_client = None _default_client = None