mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
Vendored
+172
-12
@@ -16,7 +16,7 @@ import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
|
||||
from typing import Any, Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
|
||||
|
||||
import aiofiles
|
||||
@@ -84,6 +84,14 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
|
||||
}
|
||||
|
||||
InstalledPluginsProvider = Callable[[], List[str]]
|
||||
PluginInstallGateway = Callable[
|
||||
[str, str, Optional[str], Optional[str], bool],
|
||||
Tuple[bool, str],
|
||||
]
|
||||
AsyncPluginInstallGateway = Callable[
|
||||
[str, str, Optional[str], Optional[str], bool],
|
||||
Awaitable[Tuple[bool, str]],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -101,6 +109,34 @@ def _empty_installed_plugins() -> List[str]:
|
||||
|
||||
_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins
|
||||
|
||||
|
||||
def _unconfigured_plugin_install_gateway(
|
||||
_pid: str,
|
||||
_repo_url: str,
|
||||
_package_version: Optional[str],
|
||||
_release_version: Optional[str],
|
||||
_force_install: bool,
|
||||
) -> Tuple[bool, str]:
|
||||
"""在组合根尚未装配来源门禁时拒绝插件包写入。"""
|
||||
return False, "插件安装服务尚未完成初始化"
|
||||
|
||||
|
||||
async def _unconfigured_async_plugin_install_gateway(
|
||||
_pid: str,
|
||||
_repo_url: str,
|
||||
_package_version: Optional[str],
|
||||
_release_version: Optional[str],
|
||||
_force_install: bool,
|
||||
) -> Tuple[bool, str]:
|
||||
"""在组合根尚未装配来源门禁时拒绝异步插件包写入。"""
|
||||
return False, "插件安装服务尚未完成初始化"
|
||||
|
||||
|
||||
_plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway
|
||||
_async_plugin_install_gateway: AsyncPluginInstallGateway = (
|
||||
_unconfigured_async_plugin_install_gateway
|
||||
)
|
||||
|
||||
def configure_installed_plugins_provider(
|
||||
provider: InstalledPluginsProvider,
|
||||
) -> None:
|
||||
@@ -109,6 +145,24 @@ def configure_installed_plugins_provider(
|
||||
_installed_plugins_provider = provider
|
||||
|
||||
|
||||
def configure_plugin_install_gateway(
|
||||
*,
|
||||
install: PluginInstallGateway,
|
||||
async_install: AsyncPluginInstallGateway,
|
||||
) -> None:
|
||||
"""由启动组合根装配公开兼容安装入口的来源门禁。"""
|
||||
global _plugin_install_gateway, _async_plugin_install_gateway
|
||||
_plugin_install_gateway = install
|
||||
_async_plugin_install_gateway = async_install
|
||||
|
||||
|
||||
def reset_plugin_install_gateway() -> None:
|
||||
"""恢复未装配状态,供隔离测试清理进程级安装入口。"""
|
||||
global _plugin_install_gateway, _async_plugin_install_gateway
|
||||
_plugin_install_gateway = _unconfigured_plugin_install_gateway
|
||||
_async_plugin_install_gateway = _unconfigured_async_plugin_install_gateway
|
||||
|
||||
|
||||
def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""规范化插件仓库地址,便于跨来源合并去重。"""
|
||||
repo_url = (repo_url or "").strip().rstrip("/")
|
||||
@@ -518,6 +572,11 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
candidate["repo_order"] = repo_order
|
||||
candidate["repo_path"] = repo_path
|
||||
candidate["path"] = plugin_dir
|
||||
candidate["repo_url"] = self.make_local_repo_url(
|
||||
pid,
|
||||
repo_path,
|
||||
package_version or None,
|
||||
)
|
||||
self.annotate_plugin_system_version(candidate)
|
||||
candidate_version = str(candidate.get("version") or "0")
|
||||
|
||||
@@ -583,6 +642,11 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
candidate["repo_order"] = repo_order
|
||||
candidate["repo_path"] = local_repo_path
|
||||
candidate["path"] = plugin_dir
|
||||
candidate["repo_url"] = self.make_local_repo_url(
|
||||
candidate_pid,
|
||||
local_repo_path,
|
||||
current_package_version or None,
|
||||
)
|
||||
if not is_compatible:
|
||||
candidate["compatible"] = False
|
||||
candidate["skip_reason"] = (
|
||||
@@ -798,11 +862,34 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
releases.extend(cls.__normalize_plugin_release_response(payload))
|
||||
return len(payload) >= 100
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
def get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""读取插件索引;404 返回 None,读取失败由调用方记录。"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
raise ValueError("插件仓库地址无效")
|
||||
package_url, headers = request
|
||||
res = self.__request_with_fallback(package_url, headers=headers)
|
||||
if res is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
|
||||
payload = self.__parse_plugin_index_response(res.text)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
def get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表
|
||||
获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
@@ -955,16 +1042,32 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
release_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""
|
||||
安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
|
||||
通过宿主统一 Gateway 安装插件,保留第三方插件使用的同步兼容 API。
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:param force_install: 是否替换已存在的插件载荷
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
return _plugin_install_gateway(
|
||||
pid,
|
||||
repo_url,
|
||||
package_version,
|
||||
release_version,
|
||||
force_install,
|
||||
)
|
||||
|
||||
def __install_package(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""执行已通过来源准入的同步包安装,不负责身份或运行态提交。"""
|
||||
if self.is_local_repo_url(repo_url):
|
||||
return self.install_local(pid=pid, repo_url=repo_url, force_install=force_install)
|
||||
return self.__install_local_package(
|
||||
pid=pid,
|
||||
repo_url=repo_url,
|
||||
force_install=force_install,
|
||||
)
|
||||
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
@@ -1045,8 +1148,24 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
|
||||
|
||||
def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]:
|
||||
"""通过宿主统一 Gateway 安装本地插件。"""
|
||||
target_repo = repo_url or self.make_local_repo_url(pid)
|
||||
return _plugin_install_gateway(
|
||||
pid,
|
||||
target_repo,
|
||||
self.parse_local_repo_package_version(target_repo),
|
||||
None,
|
||||
force_install,
|
||||
)
|
||||
|
||||
def __install_local_package(
|
||||
self,
|
||||
pid: str,
|
||||
repo_url: str = "",
|
||||
force_install: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
从本地插件仓库目录安装插件
|
||||
执行已通过来源准入的本地插件包安装。
|
||||
"""
|
||||
local_pid = self.parse_local_repo_url(repo_url) if repo_url else pid
|
||||
if not local_pid or local_pid.lower() != pid.lower():
|
||||
@@ -2158,7 +2277,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
self.refresh_persistent_plugin_backup(pid)
|
||||
if backup_dir:
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
return True, ""
|
||||
@@ -2433,11 +2551,37 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
|
||||
return None
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
async def async_get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""异步读取插件索引;404 返回 None,读取失败由调用方记录。"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
raise ValueError("插件仓库地址无效")
|
||||
package_url, headers = request
|
||||
res = await self.__async_request_with_fallback(
|
||||
package_url,
|
||||
headers=headers,
|
||||
)
|
||||
if res is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
|
||||
payload = self.__parse_plugin_index_response(res.text)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
async def async_get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
异步获取Github所有最新插件列表
|
||||
异步获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
@@ -3042,17 +3186,34 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
release_version: Optional[str] = None,
|
||||
force_install: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
|
||||
通过宿主统一 Gateway 安装插件,保留第三方插件使用的异步兼容 API。
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:param force_install: 是否替换已存在的插件载荷
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
return await _async_plugin_install_gateway(
|
||||
pid,
|
||||
repo_url,
|
||||
package_version,
|
||||
release_version,
|
||||
force_install,
|
||||
)
|
||||
|
||||
async def __async_install_package(
|
||||
self,
|
||||
pid: str,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None,
|
||||
force_install: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""执行已通过来源准入的异步包安装,不负责身份或运行态提交。"""
|
||||
if self.is_local_repo_url(repo_url):
|
||||
return await _await_thread_operation(
|
||||
self.install_local,
|
||||
self.__install_local_package,
|
||||
pid,
|
||||
repo_url,
|
||||
force_install,
|
||||
@@ -3187,7 +3348,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
await _await_thread_operation(self.refresh_persistent_plugin_backup, pid)
|
||||
return True, ""
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(
|
||||
|
||||
+31
-2
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from app.adapters.external.market import PluginHelper as _PluginHelper
|
||||
from app.runtime.cache import async_fresh, fresh
|
||||
@@ -21,7 +21,7 @@ class PluginMarketClient:
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict]]:
|
||||
) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""同步读取指定仓库和代际的插件索引。"""
|
||||
with fresh(force):
|
||||
return self._helper.get_plugins(repo_url, package_version)
|
||||
@@ -36,6 +36,35 @@ class PluginMarketClient:
|
||||
async with async_fresh(force):
|
||||
return await self._helper.async_get_plugins(repo_url, package_version)
|
||||
|
||||
def get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict]]:
|
||||
"""读取插件索引的三态结果,供库存读取保留失败事实。"""
|
||||
with fresh(force):
|
||||
return cast(
|
||||
Optional[dict[str, dict[str, Any]]],
|
||||
self._helper.get_plugin_index_result(repo_url, package_version),
|
||||
)
|
||||
|
||||
async def async_get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""异步读取插件索引的三态结果,供库存读取保留失败事实。"""
|
||||
async with async_fresh(force):
|
||||
return cast(
|
||||
Optional[dict[str, dict[str, Any]]],
|
||||
await self._helper.async_get_plugin_index_result(
|
||||
repo_url,
|
||||
package_version,
|
||||
),
|
||||
)
|
||||
|
||||
def get_local_candidates(self) -> dict[str, dict]:
|
||||
"""返回全部本地插件仓库候选。"""
|
||||
return self._helper.get_local_plugin_candidates()
|
||||
|
||||
Reference in New Issue
Block a user