From 554a6f59bd6b54aae94bd2597127adb4945f7675 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sat, 1 Mar 2025 15:58:41 +0800 Subject: [PATCH] Add plugin related interfaces (#535) * Add plugin related interfaces * Update interface auth * Add build plugin zip service --- backend/app/admin/api/v1/sys/__init__.py | 2 + backend/app/admin/api/v1/sys/plugin.py | 97 ++++++++++++++++++++++++ backend/core/conf.py | 4 + backend/plugin/tools.py | 39 ++++++---- 4 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 backend/app/admin/api/v1/sys/plugin.py diff --git a/backend/app/admin/api/v1/sys/__init__.py b/backend/app/admin/api/v1/sys/__init__.py index 87367738..234479dd 100644 --- a/backend/app/admin/api/v1/sys/__init__.py +++ b/backend/app/admin/api/v1/sys/__init__.py @@ -8,6 +8,7 @@ from backend.app.admin.api.v1.sys.dept import router as dept_router from backend.app.admin.api.v1.sys.dict_data import router as dict_data_router from backend.app.admin.api.v1.sys.dict_type import router as dict_type_router from backend.app.admin.api.v1.sys.menu import router as menu_router +from backend.app.admin.api.v1.sys.plugin import router as plugin_router from backend.app.admin.api.v1.sys.role import router as role_router from backend.app.admin.api.v1.sys.token import router as token_router from backend.app.admin.api.v1.sys.upload import router as upload_router @@ -25,3 +26,4 @@ router.include_router(user_router, prefix='/users', tags=['系统用户']) router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据权限规则']) router.include_router(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(upload_router, prefix='/upload', tags=['系统上传']) +router.include_router(plugin_router, prefix='/plugin', tags=['系统插件']) diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py new file mode 100644 index 00000000..cf291c69 --- /dev/null +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import io +import os.path +import zipfile + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, UploadFile +from fastapi.params import Query +from starlette.responses import StreamingResponse + +from backend.common.exception import errors +from backend.common.response.response_schema import ResponseModel, response_base +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.core.path_conf import PLUGIN_DIR +from backend.plugin.tools import install_requirements_async + +router = APIRouter() + + +@router.post( + '/install', + summary='安装插件', + description='需使用插件 zip 压缩包进行安装', + dependencies=[ + Depends(RequestPermission('sys:plugin:install')), + DependsRBAC, + ], +) +async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: + contents = await file.read() + file_bytes = io.BytesIO(contents) + if not zipfile.is_zipfile(file_bytes): + raise errors.ForbiddenError(msg='插件压缩包格式非法') + with zipfile.ZipFile(file_bytes) as zf: + # 校验压缩包 + plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/' + members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)] + if not members_in_plugin_dir: + raise errors.ForbiddenError(msg='插件压缩包内容非法') + plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '') + if ( + len(members_in_plugin_dir) <= 3 + or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir + or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir + ): + raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件') + + # 插件是否可安装 + full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) + if os.path.exists(full_plugin_path): + raise errors.ForbiddenError(msg='此插件已安装') + os.makedirs(full_plugin_path) + + # 解压安装 + members = [] + for member in zf.infolist(): + if member.filename.startswith(plugin_dir_in_zip): + member.filename = member.filename.replace(plugin_dir_in_zip, '') + if not member.filename: + continue + members.append(member) + zf.extractall(PLUGIN_DIR, members) + if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')): + await install_requirements_async(False) + + return response_base.success() + + +@router.post( + '/zip', + summary='打包插件', + dependencies=[ + Depends(RequestPermission('sys:plugin:zip')), + DependsRBAC, + ], +) +async def build_plugin_zip(plugin: Annotated[str, Query()]): + plugin_dir = os.path.join(PLUGIN_DIR, plugin) + if not os.path.exists(plugin_dir): + raise errors.ForbiddenError(msg='插件不存在') + bio = io.BytesIO() + with zipfile.ZipFile(bio, 'w') as zf: + for root, dirs, files in os.walk(plugin_dir): + dirs[:] = [d for d in dirs if d != '__pycache__'] + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, start=plugin_dir) + zf.write(file_path, arcname) + bio.seek(0) + return StreamingResponse( + bio, + media_type='application/x-zip-compressed', + headers={'Content-Disposition': f'attachment; filename={plugin}.zip'}, + ) diff --git a/backend/core/conf.py b/backend/core/conf.py index f9ea7642..530c9f3f 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -178,6 +178,10 @@ class Settings(BaseSettings): 'updated_time', ] + # Plugin + PLUGIN_PIP_CHINA: bool = True + PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/' + @model_validator(mode='before') @classmethod def check_env(cls, values: Any) -> Any: diff --git a/backend/plugin/tools.py b/backend/plugin/tools.py index bb424fe0..1e1566aa 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -13,6 +13,7 @@ import rtoml from fastapi import APIRouter +from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.utils.import_parse import import_module_cached @@ -156,13 +157,21 @@ def install_requirements() -> None: else: try: subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade']) - subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-r', requirements_file]) + pip_requirements = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] + if settings.PLUGIN_PIP_CHINA: + pip_requirements.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) + subprocess.check_call(pip_requirements) except subprocess.CalledProcessError as e: raise PluginInjectError(f'插件 {plugin} 依赖安装失败:{e}') from e -async def install_requirements_async() -> None: - """异步安装插件依赖""" +async def install_requirements_async(wait: bool = True) -> None: + """ + 异步安装插件依赖 + + :param wait: 是否等待结果并校验,开启将造成 IO 阻塞 + :return: + """ plugins = get_plugins() for plugin in plugins: requirements_file = os.path.join(PLUGIN_DIR, plugin, 'requirements.txt') @@ -177,19 +186,19 @@ async def install_requirements_async() -> None: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - _, ensurepip_stderr = await ensurepip_process.communicate() - if ensurepip_process.returncode != 0: - raise PluginInjectError(f'ensurepip 安装失败:{ensurepip_stderr}') + if wait: + _, ensurepip_stderr = await ensurepip_process.communicate() + if ensurepip_process.returncode != 0: + raise PluginInjectError(f'ensurepip 安装失败:{ensurepip_stderr}') + pip_requirements = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] + if settings.PLUGIN_PIP_CHINA: + pip_requirements.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) pip_process = await async_subprocess.create_subprocess_exec( - sys.executable, - '-m', - 'pip', - 'install', - '-r', - requirements_file, + *pip_requirements, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - _, pip_stderr = await pip_process.communicate() - if pip_process.returncode != 0: - raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{pip_stderr}') + if wait: + _, pip_stderr = await pip_process.communicate() + if pip_process.returncode != 0: + raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{pip_stderr}')