#!/usr/bin/env python3 # -*- coding: utf-8 -*- import io import json import os import shutil import zipfile from typing import Any from dulwich import porcelain from fastapi import UploadFile from backend.common.enums import StatusType from backend.common.exception import errors from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client from backend.plugin.tools import install_requirements_async, uninstall_requirements_async from backend.utils.re_verify import is_git_url from backend.utils.timezone import timezone class PluginService: """插件服务类""" @staticmethod async def get_all() -> list[dict[str, Any]]: """获取所有插件""" keys = [] result = [] async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'): keys.append(key) for info in await redis_client.mget(*keys): result.append(json.loads(info)) return result @staticmethod async def install_zip(*, file: UploadFile) -> None: """ 通过 zip 压缩包安装插件 :param file: 插件 zip 压缩包 :return: """ 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 = file.filename[:-4] members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir)] if not members_in_plugin_dir: raise errors.ForbiddenError(msg='插件压缩包内容非法') plugin_name = ( members_in_plugin_dir[0] .replace('/', '') .replace('-master', '') .replace('-main', '') .replace('-dev', '') ) if ( len(members_in_plugin_dir) <= 3 or f'{plugin_dir}/plugin.toml' not in members_in_plugin_dir or f'{plugin_dir}/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='此插件已安装') else: os.makedirs(full_plugin_path, exist_ok=True) # 解压(安装) members = [] for member in zf.infolist(): if member.filename.startswith(plugin_dir): new_filename = member.filename.replace(plugin_dir, '') if new_filename: member.filename = new_filename members.append(member) zf.extractall(os.path.join(PLUGIN_DIR, plugin_name), members) await install_requirements_async(plugin_name) @staticmethod async def install_git(*, repo_url: str): """ 通过 git 安装插件 :param repo_url: git 存储库的 URL :return: """ match = is_git_url(repo_url) if not match: raise errors.ForbiddenError(msg='Git 仓库地址格式非法') repo_name = match.group('repo') plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1) if repo_name in plugins: raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装') try: porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) except Exception as e: log.error(f'插件安装失败: {e}') raise errors.ServerError(msg='插件安装失败,请稍后重试') from e else: await install_requirements_async(repo_name) @staticmethod async def uninstall(*, plugin: str): """ 卸载插件 :param plugin: 插件名称 :return: """ plugin_dir = os.path.join(PLUGIN_DIR, plugin) if not os.path.exists(plugin_dir): raise errors.ForbiddenError(msg='插件不存在') await uninstall_requirements_async(plugin) bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') shutil.move(plugin_dir, bacup_dir) @staticmethod async def update_status(*, plugin: str): """ 更新插件状态 :param plugin: 插件名称 :return: """ plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}') if not plugin_info: raise errors.ForbiddenError(msg='插件不存在') plugin_info = json.loads(plugin_info) new_status = ( StatusType.enable.value if plugin_info.get('plugin', {}).get('enable') == StatusType.disable.value else StatusType.disable.value ) plugin_info['plugin']['enable'] = new_status await redis_client.set( f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False) ) await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, str(new_status)) @staticmethod async def build(*, plugin: str) -> io.BytesIO: """ 打包插件为 zip 压缩包 :param plugin: 插件名称 :return: """ 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) return bio plugin_service: PluginService = PluginService()