import mimetypes import shutil from typing import Annotated, Any, List, Optional import aiofiles from anyio import Path as AsyncPath from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.concurrency import run_in_threadpool from starlette import status from starlette.responses import StreamingResponse from app import schemas from app.command import Command from app.core.config import settings from app.core.plugin import PluginManager from app.core.security import verify_apikey, verify_token from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async from app.factory import app from app.helper.plugin import PluginHelper from app.log import logger from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"} PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" router = APIRouter() def register_plugin_api(plugin_id: Optional[str] = None): """ 动态注册插件 API :param plugin_id: 插件 ID,如果为 None,则注册所有插件 """ _update_plugin_api_routes(plugin_id, action="add") def remove_plugin_api(plugin_id: str): """ 动态移除单个插件的 API :param plugin_id: 插件 ID """ _update_plugin_api_routes(plugin_id, action="remove") def _update_plugin_api_routes(plugin_id: Optional[str], action: str): """ 插件 API 路由注册和移除 :param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件 如果 action 为 "remove",plugin_id 必须是有效的插件 ID :param action: "add" 或 "remove",决定是添加还是移除路由 """ if action not in {"add", "remove"}: raise ValueError("Action must be 'add' or 'remove'") is_modified = False existing_paths = {route.path: route for route in app.routes} plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids() for plugin_id in plugin_ids: routes_removed = _remove_routes(plugin_id) if routes_removed: is_modified = True if action != "add": continue # 获取插件的 API 路由信息 plugin_apis = PluginManager().get_plugin_apis(plugin_id) for api in plugin_apis: api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}" try: api["path"] = api_path allow_anonymous = api.pop("allow_anonymous", False) auth_mode = api.pop("auth", "apikey") dependencies = api.setdefault("dependencies", []) if not allow_anonymous: if auth_mode == "bear" and Depends(verify_token) not in dependencies: dependencies.append(Depends(verify_token)) elif Depends(verify_apikey) not in dependencies: dependencies.append(Depends(verify_apikey)) app.add_api_route(**api, tags=["plugin"]) is_modified = True logger.debug(f"Added plugin route: {api_path}") except Exception as e: logger.error(f"Error adding plugin route {api_path}: {str(e)}") if is_modified: _clean_protected_routes(existing_paths) app.openapi_schema = None app.setup() def _remove_routes(plugin_id: str) -> bool: """ 移除与单个插件相关的路由 :param plugin_id: 插件 ID :return: 是否有路由被移除 """ if not plugin_id: return False prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)] removed = False for route in routes_to_remove: try: app.routes.remove(route) removed = True logger.debug(f"Removed plugin route: {route.path}") except Exception as e: logger.error(f"Error removing plugin route {route.path}: {str(e)}") return removed def _clean_protected_routes(existing_paths: dict): """ 清理受保护的路由,防止在插件操作中被删除或重复添加 :param existing_paths: 当前应用的路由路径映射 """ for protected_route in PROTECTED_ROUTES: try: existing_route = existing_paths.get(protected_route) if existing_route: app.routes.remove(existing_route) except Exception as e: logger.error(f"Error removing protected route {protected_route}: {str(e)}") def register_plugin(plugin_id: str): """ 注册一个插件相关的服务 """ # 注册插件服务 Scheduler().update_plugin_job(plugin_id) # 注册菜单命令 Command().init_commands(plugin_id) # 注册插件API register_plugin_api(plugin_id) @router.get("/", summary="所有插件", response_model=List[schemas.Plugin]) async def all_plugins(_: User = Depends(get_current_active_superuser_async), state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]: """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ # 本地插件 plugin_manager = PluginManager() local_plugins = plugin_manager.get_local_plugins() # 已安装插件 installed_plugins = [plugin for plugin in local_plugins if plugin.installed] if state == "installed": return installed_plugins # 未安装的本地插件 not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed] # 在线插件 online_plugins = await plugin_manager.async_get_online_plugins(force) if not online_plugins: # 没有获取在线插件 if state == "market": # 返回未安装的本地插件 return not_installed_plugins return local_plugins # 插件市场插件清单 market_plugins = [] # 已安装插件IDS _installed_ids = [plugin.id for plugin in installed_plugins] # 未安装的线上插件或者有更新的插件 for plugin in online_plugins: if plugin.id not in _installed_ids: market_plugins.append(plugin) elif plugin.has_update: market_plugins.append(plugin) # 未安装的本地插件,且不在线上插件中 _plugin_ids = [plugin.id for plugin in market_plugins] for plugin in not_installed_plugins: if plugin.id not in _plugin_ids: market_plugins.append(plugin) # 返回插件清单 if state == "market": # 返回未安装的插件 return market_plugins # 返回所有插件 return installed_plugins + market_plugins @router.get("/installed", summary="已安装插件", response_model=List[str]) async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any: """ 查询用户已安装插件清单 """ return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] @router.get("/statistic", summary="插件安装统计", response_model=dict) async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 插件安装统计 """ return await PluginHelper().async_get_statistic() @router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response) def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 重新加载插件 """ # 重新加载插件 PluginManager().reload_plugin(plugin_id) # 注册插件服务 register_plugin(plugin_id) return schemas.Response(success=True) @router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response) async def install(plugin_id: str, repo_url: Optional[str] = "", force: Optional[bool] = False, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 安装插件 """ # 已安装插件 install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 plugin_helper = PluginHelper() if not force and plugin_id in PluginManager().get_plugin_ids(): await plugin_helper.async_install_reg(pid=plugin_id) else: # 插件不存在或需要强制安装,下载安装并注册插件 if repo_url: state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url) # 安装失败则直接响应 if not state: return schemas.Response(success=False, message=msg) else: # repo_url 为空时,也直接响应 return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置") # 安装插件 if plugin_id not in install_plugins: install_plugins.append(plugin_id) # 保存设置 await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins) # 重新加载插件 await run_in_threadpool(reload_plugin, plugin_id) return schemas.Response(success=True) @router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict]) async def remotes(token: str) -> Any: """ 获取插件联邦组件列表 """ if token != "moviepilot": raise HTTPException(status_code=403, detail="Forbidden") return PluginManager().get_plugin_remotes() @router.get("/form/{plugin_id}", summary="获取插件表单页面") def plugin_form(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict: """ 根据插件ID获取插件配置表单或Vue组件URL """ plugin_manager = PluginManager() plugin_instance = plugin_manager.running_plugins.get(plugin_id) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() try: conf, model = plugin_instance.get_form() return { "render_mode": render_mode, "conf": conf, "model": plugin_manager.get_plugin_config(plugin_id) or model } except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") return {} @router.get("/page/{plugin_id}", summary="获取插件数据页面") def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict: """ 根据插件ID获取插件数据页面 """ plugin_instance = PluginManager().running_plugins.get(plugin_id) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() try: page = plugin_instance.get_page() return { "render_mode": render_mode, "page": page or [] } except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") return {} @router.get("/dashboard/meta", summary="获取所有插件仪表板元信息") def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]: """ 获取所有插件仪表板元信息 """ return PluginManager().get_plugin_dashboard_meta() @router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: """ 根据插件ID获取插件仪表板 """ return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent) @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard: """ 根据插件ID获取插件仪表板 """ return plugin_dashboard_by_key(plugin_id, "", user_agent) @router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response) def reset_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 根据插件ID重置插件配置及数据 """ plugin_manager = PluginManager() # 删除配置 plugin_manager.delete_plugin_config(plugin_id) # 删除插件所有数据 plugin_manager.delete_plugin_data(plugin_id) # 重新加载插件 reload_plugin(plugin_id) return schemas.Response(success=True) @router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件") async def plugin_static_file(plugin_id: str, filepath: str): """ 获取插件静态文件 """ # 基础安全检查 if ".." in filepath or ".." in plugin_id: logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") plugin_base_dir = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() plugin_file_path = plugin_base_dir / filepath if not await plugin_file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在") if not await plugin_file_path.is_file(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件") # 判断 MIME 类型 response_type, _ = mimetypes.guess_type(str(plugin_file_path)) suffix = plugin_file_path.suffix.lower() # 强制修正 .mjs 和 .js 的 MIME 类型 if suffix in ['.js', '.mjs']: response_type = 'application/javascript' elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 response_type = 'text/css' elif not response_type: # 对于其他猜不出的类型 response_type = 'application/octet-stream' try: # 异步生成器函数,用于流式读取文件 async def file_generator(): async with aiofiles.open(plugin_file_path, mode='rb') as file: # 8KB 块大小 while chunk := await file.read(8192): yield chunk return StreamingResponse( file_generator(), media_type=response_type, headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"} ) except Exception as e: logger.error(f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal Server Error") @router.get("/folders", summary="获取插件文件夹配置", response_model=dict) async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict: """ 获取插件文件夹分组配置 """ try: result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} return result except Exception as e: logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}") return {} @router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response) async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 保存插件文件夹分组配置 """ try: SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True) except Exception as e: logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}") return schemas.Response(success=False, message=str(e)) @router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response) async def create_plugin_folder(folder_name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 创建新的插件文件夹 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} if folder_name not in folders: folders[folder_name] = [] SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功") else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在") @router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response) async def delete_plugin_folder(folder_name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 删除插件文件夹 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} if folder_name in folders: del folders[folder_name] await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功") else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在") @router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response) async def update_folder_plugins(folder_name: str, plugin_ids: List[str], _: User = Depends(get_current_active_superuser_async)) -> Any: """ 更新指定文件夹中的插件列表 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} folders[folder_name] = plugin_ids await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新") @router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response) def clone_plugin(plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 创建插件分身 """ try: success, message = PluginManager().clone_plugin( plugin_id=plugin_id, suffix=clone_data.get("suffix", ""), name=clone_data.get("name", ""), description=clone_data.get("description", ""), version=clone_data.get("version", ""), icon=clone_data.get("icon", "") ) if success: # 注册插件服务 reload_plugin(message) # 将分身插件添加到原插件所在的文件夹中 _add_clone_to_plugin_folder(plugin_id, message) return schemas.Response(success=True, message="插件分身创建成功") else: return schemas.Response(success=False, message=message) except Exception as e: logger.error(f"创建插件分身失败:{str(e)}") return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}") @router.get("/{plugin_id}", summary="获取插件配置") async def plugin_config(plugin_id: str, _: User = Depends(get_current_active_superuser_async)) -> dict: """ 根据插件ID获取插件配置信息 """ return PluginManager().get_plugin_config(plugin_id) @router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response) def set_plugin_config(plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 更新插件配置 """ plugin_manager = PluginManager() # 保存配置 plugin_manager.save_plugin_config(plugin_id, conf) # 重新生效插件 plugin_manager.init_plugin(plugin_id, conf) # 注册插件服务 register_plugin(plugin_id) return schemas.Response(success=True) @router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response) def uninstall_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 卸载插件 """ config_oper = SystemConfigOper() # 删除已安装信息 install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] for plugin in install_plugins: if plugin == plugin_id: install_plugins.remove(plugin) break config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins) # 移除插件API remove_plugin_api(plugin_id) # 移除插件服务 Scheduler().remove_plugin_job(plugin_id) # 判断是否为分身 plugin_manager = PluginManager() plugin_class = plugin_manager.plugins.get(plugin_id) if getattr(plugin_class, "is_clone", False): # 如果是分身插件,则删除分身数据和配置 plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) # 删除分身文件 plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower() if plugin_base_dir.exists(): try: shutil.rmtree(plugin_base_dir) plugin_manager.plugins.pop(plugin_id, None) except Exception as e: logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}") # 从插件文件夹中移除该插件 _remove_plugin_from_folders(plugin_id) # 移除插件 plugin_manager.remove_plugin(plugin_id) return schemas.Response(success=True) def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): """ 将分身插件添加到原插件所在的文件夹中 :param original_plugin_id: 原插件ID :param clone_plugin_id: 分身插件ID """ try: config_oper = SystemConfigOper() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} # 查找原插件所在的文件夹 target_folder = None for folder_name, folder_data in folders.items(): if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} if original_plugin_id in folder_data['plugins']: target_folder = folder_name break elif isinstance(folder_data, list): # 旧格式:直接是插件列表 if original_plugin_id in folder_data: target_folder = folder_name break # 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中 if target_folder: folder_data = folders[target_folder] if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式 if clone_plugin_id not in folder_data['plugins']: folder_data['plugins'].append(clone_plugin_id) logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") elif isinstance(folder_data, list): # 旧格式 if clone_plugin_id not in folder_data: folder_data.append(clone_plugin_id) logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") # 保存更新后的文件夹配置 config_oper.set(SystemConfigKey.PluginFolders, folders) else: logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立") except Exception as e: logger.error(f"处理插件文件夹时出错:{str(e)}") # 文件夹处理失败不影响插件分身创建的整体流程 def _remove_plugin_from_folders(plugin_id: str): """ 从所有文件夹中移除指定的插件 :param plugin_id: 要移除的插件ID """ try: config_oper = SystemConfigOper() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} # 标记是否有修改 modified = False # 遍历所有文件夹,移除指定插件 for folder_name, folder_data in folders.items(): if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} if plugin_id in folder_data['plugins']: folder_data['plugins'].remove(plugin_id) logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") modified = True elif isinstance(folder_data, list): # 旧格式:直接是插件列表 if plugin_id in folder_data: folder_data.remove(plugin_id) logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") modified = True # 如果有修改,保存更新后的文件夹配置 if modified: config_oper.set(SystemConfigKey.PluginFolders, folders) else: logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") except Exception as e: logger.error(f"从文件夹中移除插件时出错:{str(e)}") # 文件夹处理失败不影响插件卸载的整体流程