mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-13 00:13:02 +08:00
feat: enhance user permissions handling for admin and non-admin contexts
This commit is contained in:
@@ -4,6 +4,7 @@ import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
@@ -373,6 +374,119 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
# 独立的新 dict,跨工具状态(例如质量门槛拒绝标记)无法传播。
|
||||
self._agent_context = {} if agent_context is None else agent_context
|
||||
|
||||
async def is_admin_user(self) -> bool:
|
||||
"""
|
||||
判断当前工具调用者是否拥有管理员级权限。
|
||||
|
||||
:return: 当前调用者是系统管理员、渠道管理员或显式管理员上下文时返回 True
|
||||
"""
|
||||
if bool(self._agent_context.get("is_admin")):
|
||||
return True
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
return await self._has_channel_admin_permission()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_local_path(path: str) -> Path:
|
||||
"""
|
||||
解析本地路径并展开符号链接。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:return: 规范化后的绝对路径
|
||||
"""
|
||||
return Path(path).expanduser().resolve(strict=False)
|
||||
|
||||
@staticmethod
|
||||
def _is_path_relative_to(path: Path, root: Path) -> bool:
|
||||
"""
|
||||
判断路径是否位于指定目录内。
|
||||
|
||||
:param path: 待检查路径
|
||||
:param root: 允许访问的根目录
|
||||
:return: 路径在根目录内或等于根目录时返回 True
|
||||
"""
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get_non_admin_local_file_roots(cls) -> list[Path]:
|
||||
"""
|
||||
获取普通用户可访问的本地文件根目录。
|
||||
|
||||
:return: 普通用户允许读写的本地目录列表
|
||||
"""
|
||||
roots = [
|
||||
settings.CONFIG_PATH,
|
||||
settings.LOG_PATH,
|
||||
settings.CONFIG_PATH / "agent" / "memory",
|
||||
settings.CONFIG_PATH / "agent" / "activity",
|
||||
]
|
||||
resolved_roots = []
|
||||
for root in roots:
|
||||
resolved_root = cls._resolve_local_path(str(root))
|
||||
if resolved_root not in resolved_roots:
|
||||
resolved_roots.append(resolved_root)
|
||||
return resolved_roots
|
||||
|
||||
async def _check_local_file_access(
|
||||
self, path: str, operation: str = "访问"
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定本地路径。
|
||||
|
||||
:param path: 用户传入的本地文件或目录路径
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 解析后的路径和拒绝原因;拒绝原因为空表示允许访问
|
||||
"""
|
||||
if not path:
|
||||
return None, "错误:路径不能为空"
|
||||
|
||||
resolved_path = self._resolve_local_path(path)
|
||||
if await self.is_admin_user():
|
||||
return resolved_path, None
|
||||
|
||||
allowed_roots = self._get_non_admin_local_file_roots()
|
||||
if any(
|
||||
self._is_path_relative_to(resolved_path, root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
return resolved_path, None
|
||||
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}配置目录、Agent记忆目录和日志目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
|
||||
async def _check_local_storage_access(
|
||||
self,
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
operation: str = "访问",
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
检查当前用户是否可访问指定存储路径。
|
||||
|
||||
:param path: 用户传入的文件或目录路径
|
||||
:param storage: 存储类型,普通用户只允许 local
|
||||
:param operation: 当前操作名称,用于生成拒绝提示
|
||||
:return: 本地存储时返回解析后的路径和拒绝原因;远程存储无本地路径
|
||||
"""
|
||||
if (storage or "local") != "local":
|
||||
if await self.is_admin_user():
|
||||
return None, None
|
||||
return (
|
||||
None,
|
||||
f"抱歉,普通用户只能{operation}本地配置目录、Agent记忆目录和日志目录,不能访问远程存储。",
|
||||
)
|
||||
|
||||
return await self._check_local_file_access(path=path, operation=operation)
|
||||
|
||||
async def _check_permission(self) -> Optional[str]:
|
||||
"""
|
||||
检查用户权限:
|
||||
@@ -385,9 +499,28 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if not self._require_admin:
|
||||
return None
|
||||
|
||||
if await self.is_admin_user():
|
||||
return None
|
||||
|
||||
if not self._channel or not self._source:
|
||||
return None
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
)
|
||||
|
||||
async def _has_channel_admin_permission(self) -> bool:
|
||||
"""
|
||||
检查当前消息渠道身份是否具备管理员权限。
|
||||
|
||||
:return: 当前渠道用户是渠道管理员、系统管理员或默认接收人时返回 True
|
||||
"""
|
||||
if not self._channel or not self._source:
|
||||
return False
|
||||
|
||||
# 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取;
|
||||
# 只有用户信息需要走异步数据库查询。
|
||||
user_id_str = str(self._user_id) if self._user_id else None
|
||||
@@ -411,7 +544,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
break
|
||||
|
||||
if not channel_type:
|
||||
return None
|
||||
return False
|
||||
|
||||
admin_key_map = {
|
||||
"telegram": "TELEGRAM_ADMINS",
|
||||
@@ -451,7 +584,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
if aid.strip()
|
||||
]
|
||||
if user_id_str and user_id_str in admin_list:
|
||||
return None
|
||||
return True
|
||||
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -459,14 +592,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有渠道管理员或系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系渠道管理员将您的用户ID添加到渠道管理员列表中,"
|
||||
"或联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
user = (
|
||||
await UserOper().async_get_by_name(self._username)
|
||||
@@ -474,22 +602,18 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
else None
|
||||
)
|
||||
if user and user.is_superuser:
|
||||
return None
|
||||
return True
|
||||
|
||||
if user_id_key:
|
||||
config_user_id = config.config.get(user_id_key)
|
||||
if config_user_id and str(config_user_id) == user_id_str:
|
||||
return None
|
||||
return True
|
||||
|
||||
return (
|
||||
"抱歉,您没有执行此工具的权限。"
|
||||
"只有系统管理员才能执行工具操作。"
|
||||
"如需执行工具,请联系系统管理员为您设置权限。"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"检查权限失败: {e}")
|
||||
|
||||
return None
|
||||
return False
|
||||
|
||||
async def send_tool_message(
|
||||
self, message: str, title: str = "", image: Optional[str] = None
|
||||
|
||||
@@ -83,7 +83,6 @@ class AskUserChoiceTool(MoviePilotTool):
|
||||
"back as the user's next message. Do not also send the same question as plain text."
|
||||
)
|
||||
args_schema: Type[BaseModel] = AskUserChoiceInput
|
||||
require_admin: bool = False
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
message = kwargs.get("message", "") or ""
|
||||
|
||||
@@ -24,11 +24,13 @@ class EditFileTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Edit a file by replacing specific old text with new text. Useful for modifying configuration files, code, or scripts."
|
||||
description: str = (
|
||||
"Edit a local text file by replacing specific old text with new text. "
|
||||
"Non-admin users can only edit files inside the MoviePilot config, "
|
||||
"Agent memory/activity, and log directories."
|
||||
)
|
||||
args_schema: Type[BaseModel] = EditFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据参数生成友好的提示消息"""
|
||||
@@ -40,21 +42,27 @@ class EditFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="编辑"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
|
||||
if not await path.exists():
|
||||
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
|
||||
if old_text:
|
||||
return f"错误:文件 {file_path} 不存在,无法进行内容替换。"
|
||||
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
if await path.exists():
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
if old_text not in content:
|
||||
logger.warning(f"编辑文件 {file_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {file_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
occurrences = content.count(old_text)
|
||||
new_content = content.replace(old_text, new_text)
|
||||
else:
|
||||
@@ -68,8 +76,8 @@ class EditFileTool(MoviePilotTool):
|
||||
# 写入文件
|
||||
await path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
logger.info(f"成功编辑文件 {file_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {file_path} (替换了 {occurrences} 处匹配内容)"
|
||||
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {resolved_path} (替换了 {occurrences} 处匹配内容)"
|
||||
|
||||
except PermissionError:
|
||||
return f"错误:没有访问/修改 {file_path} 的权限"
|
||||
|
||||
@@ -116,6 +116,13 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
resolved_path, access_error = await self._check_local_storage_access(
|
||||
path=path, storage=storage, operation="列出"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
if resolved_path:
|
||||
path = str(resolved_path)
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
)
|
||||
|
||||
@@ -22,10 +22,12 @@ class QueryDownloadersTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Download,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
|
||||
require_admin: bool = True
|
||||
description: str = (
|
||||
"Query downloader configuration and list available downloaders. Non-admin users receive "
|
||||
"a safe view with only the fields needed to choose a downloader, without host, account, "
|
||||
"password, token or API key values."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryDownloadersInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -37,11 +39,35 @@ class QueryDownloadersTool(MoviePilotTool):
|
||||
"""从内存配置缓存中读取下载器配置。"""
|
||||
return SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_downloaders_config(downloaders_config: list) -> list:
|
||||
"""
|
||||
生成普通用户可见的下载器配置视图。
|
||||
|
||||
:param downloaders_config: 系统下载器完整配置列表
|
||||
:return: 仅包含名称、类型和启用状态的安全配置列表
|
||||
"""
|
||||
safe_fields = ("name", "type", "enabled", "default", "priority")
|
||||
safe_downloaders = []
|
||||
for downloader in downloaders_config:
|
||||
if not isinstance(downloader, dict):
|
||||
continue
|
||||
safe_downloaders.append({
|
||||
key: downloader.get(key)
|
||||
for key in safe_fields
|
||||
if key in downloader
|
||||
})
|
||||
return safe_downloaders
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}")
|
||||
try:
|
||||
downloaders_config = self._load_downloaders_config()
|
||||
if downloaders_config:
|
||||
if not await self.is_admin_user():
|
||||
downloaders_config = self._sanitize_downloaders_config(
|
||||
downloaders_config
|
||||
)
|
||||
return json.dumps(downloaders_config, ensure_ascii=False, indent=2)
|
||||
return "未配置下载器。"
|
||||
except Exception as e:
|
||||
|
||||
@@ -30,10 +30,12 @@ class QuerySitesTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Site,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
require_admin: bool = True
|
||||
description: str = (
|
||||
"Query site status and list configured sites. Non-admin users receive a safe view "
|
||||
"that omits sensitive fields: cookie, token, API key and RSS URL. "
|
||||
"Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QuerySitesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -57,6 +59,7 @@ class QuerySitesTool(MoviePilotTool):
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: status={status}, name={name}")
|
||||
try:
|
||||
is_admin = await self.is_admin_user()
|
||||
site_oper = SiteOper()
|
||||
# 获取所有站点(按优先级排序)
|
||||
sites = await site_oper.async_list()
|
||||
@@ -82,11 +85,25 @@ class QuerySitesTool(MoviePilotTool):
|
||||
"url": s.url,
|
||||
"pri": s.pri,
|
||||
"is_active": s.is_active,
|
||||
"cookie": s.cookie,
|
||||
"downloader": s.downloader,
|
||||
"ua": s.ua,
|
||||
"proxy": s.proxy,
|
||||
"filter": s.filter,
|
||||
"render": s.render,
|
||||
"public": s.public,
|
||||
"note": s.note,
|
||||
"limit_interval": s.limit_interval,
|
||||
"limit_count": s.limit_count,
|
||||
"limit_seconds": s.limit_seconds,
|
||||
"timeout": s.timeout,
|
||||
}
|
||||
if is_admin:
|
||||
simplified.update({
|
||||
"rss": s.rss,
|
||||
"cookie": s.cookie,
|
||||
"apikey": s.apikey,
|
||||
"token": s.token,
|
||||
})
|
||||
simplified_sites.append(simplified)
|
||||
result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2)
|
||||
return result_json
|
||||
|
||||
@@ -41,13 +41,19 @@ class ReadFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="读取"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
|
||||
if not await path.exists():
|
||||
return f"错误:文件 {file_path} 不存在"
|
||||
return f"错误:文件 {resolved_path} 不存在"
|
||||
|
||||
if not await path.is_file():
|
||||
return f"错误:{file_path} 不是一个文件"
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
content = await path.read_text(encoding="utf-8")
|
||||
truncated = False
|
||||
|
||||
@@ -55,7 +55,7 @@ class SendLocalFileTool(MoviePilotTool):
|
||||
"Use this when you have generated or identified a local file the user should download."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendLocalFileInput
|
||||
require_admin: bool = False
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
file_path = kwargs.get("file_path", "")
|
||||
|
||||
@@ -44,7 +44,6 @@ class SendVoiceMessageTool(MoviePilotTool):
|
||||
"or call `send_message` with the same content."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendVoiceMessageInput
|
||||
require_admin: bool = False
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""生成语音回复工具的执行提示。"""
|
||||
|
||||
@@ -23,11 +23,12 @@ class WriteFileTool(MoviePilotTool):
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Write full content to a file. If the file already exists, it will be overwritten. Automatically creates parent directories if they don't exist."
|
||||
description: str = (
|
||||
"Write full content to a local text file. Non-admin users can only write "
|
||||
"inside the MoviePilot config, Agent memory/activity, and log directories."
|
||||
)
|
||||
args_schema: Type[BaseModel] = WriteFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据参数生成友好的提示消息"""
|
||||
@@ -39,10 +40,16 @@ class WriteFileTool(MoviePilotTool):
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
path = AsyncPath(file_path)
|
||||
resolved_path, access_error = await self._check_local_file_access(
|
||||
file_path, operation="写入"
|
||||
)
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
return f"错误:{file_path} 路径已存在但不是一个文件"
|
||||
return f"错误:{resolved_path} 路径已存在但不是一个文件"
|
||||
|
||||
# 自动创建父目录
|
||||
await path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -50,8 +57,8 @@ class WriteFileTool(MoviePilotTool):
|
||||
# 写入文件
|
||||
await path.write_text(content, encoding="utf-8")
|
||||
|
||||
logger.info(f"成功写入文件 {file_path}")
|
||||
return f"成功写入文件 {file_path}"
|
||||
logger.info(f"成功写入文件 {resolved_path}")
|
||||
return f"成功写入文件 {resolved_path}"
|
||||
|
||||
except PermissionError:
|
||||
return f"错误:没有权限写入 {file_path}"
|
||||
|
||||
@@ -55,6 +55,7 @@ class MoviePilotToolsManager:
|
||||
source="api",
|
||||
username="API Client",
|
||||
stream_handler=None,
|
||||
agent_context={"is_admin": self.is_admin},
|
||||
)
|
||||
logger.info(f"成功加载 {len(self.tools)} 个工具")
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user