CRMEB/backend/app/adminapi/controller/plugin/PluginController.php
吴昊天 89a8ea13d8 fix(安全解压): 新增 SafeZip,主题/插件 ZIP 防路径穿越;登记主题导入权限
商品 Excel 导入空单元格统一为空串,并补齐 product_type 列映射。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:24:28 +08:00

505 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\adminapi\controller\plugin;
use app\adminapi\controller\AuthController;
use app\services\system\module\ModuleInstaller;
use app\services\system\module\CreateModuleInstallLogTable;
use crmeb\plugin\PluginManager;
use crmeb\plugin\PluginManifest;
use crmeb\utils\SafeZip;
use think\facade\App;
/**
* 插件管理控制器
* 管理插件的安装/卸载/启用/禁用/上传
* Class PluginController
* @package app\adminapi\controller\plugin
*/
class PluginController extends AuthController
{
/**
* @var ModuleInstaller
*/
protected $services;
/**
* @param App $app
* @param ModuleInstaller $services
*/
public function __construct(App $app, ModuleInstaller $services)
{
parent::__construct($app);
$this->services = $services;
}
/**
* 初始化
*/
protected function initialize()
{
parent::initialize();
// 确保安装日志表 + enabled 字段存在
(new CreateModuleInstallLogTable())->up();
$this->ensureEnabledColumn();
$this->ensureDriverOwnershipColumns();
}
/**
* 确保 enabled 字段存在
*/
protected function ensureEnabledColumn(): void
{
try {
$prefix = (string) config('database.connections.mysql.prefix', 'eb_');
$db = config('database.connections.mysql.database');
$result = \think\facade\Db::query(
"SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = 'enabled'",
[$db, $prefix . 'module_install_log']
);
if (empty($result)) {
\think\facade\Db::execute(
"ALTER TABLE `{$prefix}module_install_log` ADD COLUMN `enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0未启用 1已启用' AFTER `status`"
);
}
} catch (\Throwable $e) {
// 忽略,可能已存在
}
}
/** 兼容已安装系统:为统一驱动字典补齐运行元数据和插件所有权。 */
protected function ensureDriverOwnershipColumns(): void
{
try {
$prefix = (string) config('database.connections.mysql.prefix', 'eb_');
$db = (string) config('database.connections.mysql.database');
$table = $prefix . 'driver';
$rows = \think\facade\Db::query(
'SELECT COLUMN_NAME FROM information_schema.columns'
. ' WHERE table_schema = ? AND table_name = ?'
. " AND column_name IN ('namespace', 'owner_plugin_id')",
[$db, $table]
);
$columns = array_column($rows, 'COLUMN_NAME');
if (!in_array('namespace', $columns, true)) {
\think\facade\Db::execute(
"ALTER TABLE `{$table}` ADD COLUMN `namespace` varchar(160) NOT NULL DEFAULT '' COMMENT '驱动命名空间' AFTER `class_name`"
);
}
if (!in_array('owner_plugin_id', $columns, true)) {
\think\facade\Db::execute(
"ALTER TABLE `{$table}` ADD COLUMN `owner_plugin_id` varchar(96) NOT NULL DEFAULT '' COMMENT '所属驱动插件复合标识' AFTER `namespace`"
);
}
$indexes = \think\facade\Db::query(
'SELECT INDEX_NAME FROM information_schema.statistics'
. ' WHERE table_schema = ? AND table_name = ? AND index_name = ?',
[$db, $table, 'idx_owner_plugin_id']
);
if (empty($indexes)) {
\think\facade\Db::execute(
"ALTER TABLE `{$table}` ADD INDEX `idx_owner_plugin_id` (`owner_plugin_id`)"
);
}
} catch (\Throwable $e) {
// 安装插件时会再给出明确的表结构错误,不在列表接口阶段中断后台。
}
}
/**
* 上传并安装插件 ZIP 包
*
* 接收 multipart/form-data,字段名 plugin_package
* 验证 ZIP → 提取到 plugin.json 中 path 字段对应的目录 → 返回插件元数据
* 例:path="modules/distribution" → 解压到 backend/modules/distribution/
*
* @return \think\response\Json
*/
public function upload()
{
$file = $this->request->file('plugin_package');
if (!$file) {
return app('json')->fail('请上传插件安装包(ZIP 格式)');
}
// 验证文件类型
$suffix = strtolower(pathinfo($file->getOriginalName(), PATHINFO_EXTENSION));
if ($suffix !== 'zip') {
return app('json')->fail('仅支持 ZIP 格式的插件安装包');
}
if ((int) $file->getSize() > 50 * 1024 * 1024) {
return app('json')->fail('插件安装包不能超过 50MB');
}
$targetDir = app()->getRootPath();
$tmpDir = '';
try {
// 临时目录放在 runtime(Web 根外),避免解压物被 HTTP 直接访问执行
$tmpRoot = $targetDir . 'runtime' . DIRECTORY_SEPARATOR . 'plugin_upload' . DIRECTORY_SEPARATOR;
if (!is_dir($tmpRoot) && !mkdir($tmpRoot, 0755, true) && !is_dir($tmpRoot)) {
return app('json')->fail('无法创建插件上传临时目录');
}
$tmpDir = $tmpRoot . '.plugin_tmp_' . uniqid('', true) . DIRECTORY_SEPARATOR;
if (!mkdir($tmpDir, 0755, true) && !is_dir($tmpDir)) {
return app('json')->fail('无法创建插件上传临时目录');
}
$zipPath = $tmpDir . 'package.zip';
$file->move($tmpDir, 'package.zip');
SafeZip::extract($zipPath, $tmpDir, ['require_plugin_json' => true]);
// 查找 plugin.json(可能在根目录或一级子目录)
$pluginJson = $tmpDir . 'plugin.json';
if (!is_file($pluginJson)) {
// 查找一级子目录中的 plugin.json
$subDirs = glob($tmpDir . '*/plugin.json');
if (count($subDirs) !== 1) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('ZIP 包必须且只能包含一个根级插件目录和 plugin.json');
}
$pluginJson = $subDirs[0];
}
$meta = json_decode(file_get_contents($pluginJson), true);
if (empty($meta['path']) || empty($meta['type'])) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('plugin.json 格式不完整,缺少 path 或 type 字段');
}
// 根据 type 字段确定目标父目录
// type=product/product_type → modules/product/{name}/(商品域:产品类型)
// type=activity/mode → modules/trade/{name}/(交易域:交易模式)
// type=user → modules/user/{name}/
// type=channel → modules/channel/{name}/
// type=driver → 按 plugin.json 的 path 安装到 driver/{category}/{name}/
// (驱动发现服务只扫描 driver/pay|sms|upload|express|logistics|
// invoice|email|bot|copyproduct|printer 目录,
// 安装到 modules/drivers 将导致驱动无法被扫描发现)
// 其他类型 → modules/{name}/
$typeDirMap = [
'product' => 'modules' . DIRECTORY_SEPARATOR . 'product',
'product_type' => 'modules' . DIRECTORY_SEPARATOR . 'product',
'activity' => 'modules' . DIRECTORY_SEPARATOR . 'trade',
'mode' => 'modules' . DIRECTORY_SEPARATOR . 'trade',
'user' => 'modules' . DIRECTORY_SEPARATOR . 'user',
'channel' => 'modules' . DIRECTORY_SEPARATOR . 'channel',
'marketing' => 'modules' . DIRECTORY_SEPARATOR . 'marketing',
];
$type = $meta['type'];
// 从 path 中提取插件目录名(最后一段)
$pluginName = basename($meta['path']);
if ($type === 'driver') {
// 驱动插件:path 形如 driver/{category}/{name},必须安装到对应驱动目录
// 命名空间与 autoload(driver\ → driver/)及各 DriverServices 扫描路径保持一致
$driverPath = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, trim((string)$meta['path'], '/'));
$segments = explode(DIRECTORY_SEPARATOR, $driverPath);
if (count($segments) !== 3 || $segments[0] !== 'driver'
|| in_array('..', $segments, true) || in_array('.', $segments, true)) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('驱动插件 path 格式错误,应为 driver/{category}/{name}(如 driver/sms/yunxin)');
}
// 驱动类别须有对应的发现服务,否则装完也不会被扫描到
$allowedCategories = ['pay', 'sms', 'upload', 'express', 'logistics', 'invoice', 'email', 'bot', 'copyproduct', 'printer'];
if (!in_array($segments[1], $allowedCategories, true)) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('不支持的驱动类别「' . $segments[1] . '」,支持:' . implode('/', $allowedCategories));
}
$relativePath = $driverPath;
} elseif (isset($typeDirMap[$type])) {
$relativePath = $typeDirMap[$type] . DIRECTORY_SEPARATOR . $pluginName;
} else {
$relativePath = 'modules' . DIRECTORY_SEPARATOR . $pluginName;
}
$destDir = $targetDir . $relativePath . DIRECTORY_SEPARATOR;
if (is_dir($destDir)) {
// An existing directory is an upgrade candidate. The package
// must identify exactly the same plugin and carry a higher version.
$currentManifest = PluginManifest::fromFile($destDir . 'plugin.json', $targetDir);
$newPath = trim(str_replace('\\', '/', (string) $meta['path']), '/');
if ($newPath !== trim(str_replace('\\', '/', $relativePath), '/')) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('插件 path 与安装目标不一致');
}
if ((string) ($meta['key'] ?? '') !== $currentManifest->identity->legacyKey
|| (string) ($meta['type'] ?? '') !== $currentManifest->type) {
$this->cleanTempDir($tmpDir);
return app('json')->fail('升级包不是当前插件的同一版本线');
}
$newVersion = (string) ($meta['version'] ?? '');
if ($newVersion === '' || !version_compare($newVersion, $currentManifest->version, '>')) {
$this->cleanTempDir($tmpDir);
return app('json')->fail("新版本 {$newVersion} 必须高于当前版本 {$currentManifest->version}");
}
$sourceDir = dirname($pluginJson);
$migration = $this->services->runUpgradeMigrations(
$currentManifest->identity->canonicalId(),
$sourceDir . DIRECTORY_SEPARATOR . 'upgrades',
$newVersion
);
$backupRoot = $targetDir . 'backup' . DIRECTORY_SEPARATOR . '.plugin_backup' . DIRECTORY_SEPARATOR;
$backupDir = $backupRoot . basename($relativePath) . '_' . date('YmdHis') . DIRECTORY_SEPARATOR;
if (!is_dir($backupRoot) && !mkdir($backupRoot, 0755, true) && !is_dir($backupRoot)) {
throw new \RuntimeException('无法创建插件备份目录');
}
if (!rename($destDir, $backupDir)) {
throw new \RuntimeException('无法备份旧插件文件');
}
try {
$this->copyDir($sourceDir, $destDir);
PluginManifest::fromFile($destDir . 'plugin.json', $targetDir);
} catch (\Throwable $e) {
$this->cleanTempDir($destDir);
@rename($backupDir, $destDir);
throw $e;
}
$this->services->updateInstalledVersion($currentManifest->identity->canonicalId(), $newVersion);
$this->cleanTempDir($tmpDir);
PluginManager::resetCache();
\crmeb\plugin\ModuleStateChecker::reload();
return app('json')->success('插件升级成功', [
'action' => 'upgrade',
'id' => $currentManifest->identity->canonicalId(),
'key' => $meta['key'],
'name' => $meta['name'] ?? $currentManifest->name,
'from_version' => $migration['from_version'],
'version' => $newVersion,
'steps' => $migration['steps'],
]);
}
// 从临时目录复制到目标目录
$sourceDir = dirname($pluginJson);
$this->copyDir($sourceDir, $destDir);
// 复制完成后使用统一清单模型做目录、字段、驱动信息和复合身份校验。
try {
$manifest = PluginManifest::fromFile($destDir . 'plugin.json', $targetDir);
} catch (\Throwable $e) {
$this->cleanTempDir($destDir);
throw $e;
}
//清理临时目录
$this->cleanTempDir($tmpDir);
// 重置 ModuleInstaller 缓存,使新插件可被发现
PluginManager::resetCache();
return app('json')->success('插件上传成功', [
'id' => $manifest->identity->canonicalId(),
'key' => $meta['key'] ?? $relativePath,
'name' => $meta['name'] ?? $meta['key'] ?? $pluginName,
'type' => $meta['type'],
'version' => $meta['version'] ?? '1.0.0',
'description' => $meta['description'] ?? '',
'author' => $meta['author'] ?? '',
]);
} catch (\Throwable $e) {
$this->cleanTempDir($tmpDir ?? '');
return app('json')->fail('上传失败: ' . $e->getMessage());
}
}
/**
* 递归复制目录
*/
protected function copyDir(string $src, string $dst): void
{
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$items = scandir($src);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$s = $src . DIRECTORY_SEPARATOR . $item;
$d = $dst . DIRECTORY_SEPARATOR . $item;
if (is_dir($s)) {
$this->copyDir($s, $d);
} else {
copy($s, $d);
}
}
}
/**
* 清理临时目录
*
* 跳过 __MACOSX 等 macOS ZIP 产物,处理过程中可能存在的断链等情况。
*/
protected function cleanTempDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = @scandir($dir);
if ($items === false) {
return;
}
foreach (array_diff($items, ['.', '..']) as $item) {
// 跳过 macOS 特殊目录(ZIP 产物)
if ($item === '__MACOSX' || str_starts_with($item, '._')) {
$this->removeForce($dir . DIRECTORY_SEPARATOR . $item);
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
$this->cleanTempDir($path);
@rmdir($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
/**
* 强制删除文件或目录(处理 macOS 特殊文件/断链)
*/
protected function removeForce(string $path): void
{
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_dir($path) && !is_link($path)) {
$this->cleanTempDir($path);
@rmdir($path);
} else {
@unlink($path);
}
}
/**
* 获取所有插件列表及状态
* @return \think\response\Json
*/
public function list()
{
$type = $this->request->param('type', '');
if ($type) {
$list = $this->services->getStatusListByType($type);
} else {
$list = $this->services->getStatusList();
}
return app('json')->success($list);
}
/**
* 安装插件
* @return \think\response\Json
*/
public function install()
{
$pluginKey = $this->request->param('plugin_key', '');
if (!$pluginKey) {
return app('json')->fail('请传入插件标识 plugin_key');
}
try {
$result = $this->services->install($pluginKey);
return app('json')->success('安装成功', $result);
} catch (\Throwable $e) {
return app('json')->fail('安装失败: ' . $e->getMessage());
}
}
/**
* 卸载插件
* @return \think\response\Json
*/
public function uninstall()
{
$pluginKey = $this->request->param('plugin_key', '');
if (!$pluginKey) {
return app('json')->fail('请传入插件标识 plugin_key');
}
try {
$result = $this->services->uninstall($pluginKey);
return app('json')->success('卸载成功', $result);
} catch (\Throwable $e) {
return app('json')->fail('卸载失败: ' . $e->getMessage());
}
}
/**
* 启用插件
* @return \think\response\Json
*/
public function enable()
{
$pluginKey = $this->request->param('plugin_key', '');
if (!$pluginKey) {
return app('json')->fail('请传入插件标识 plugin_key');
}
try {
$result = $this->services->enable($pluginKey);
PluginManager::resetCache();
return app('json')->success('启用成功', $result);
} catch (\Throwable $e) {
return app('json')->fail('启用失败: ' . $e->getMessage());
}
}
/**
* 禁用插件
* @return \think\response\Json
*/
public function disable()
{
$pluginKey = $this->request->param('plugin_key', '');
if (!$pluginKey) {
return app('json')->fail('请传入插件标识 plugin_key');
}
try {
$result = $this->services->disable($pluginKey);
PluginManager::resetCache();
return app('json')->success('禁用成功', $result);
} catch (\Throwable $e) {
return app('json')->fail('禁用失败: ' . $e->getMessage());
}
}
/**
* 刷新插件消息模板(TEMPORARY-PLUGIN-NOTICE-REFRESH:临时功能,后续移除)。
* 以插件 Installer::getNoticeDefinitions 为准,覆盖式更新该插件的通知/渠道模板。
* @return \think\response\Json
*/
public function refreshNotices()
{
$pluginKey = $this->request->param('plugin_key', '');
if (!$pluginKey) {
return app('json')->fail('请传入插件标识 plugin_key');
}
try {
$result = $this->services->refreshNotices($pluginKey);
return app('json')->success('刷新成功', $result);
} catch (\Throwable $e) {
return app('json')->fail('刷新失败: ' . $e->getMessage());
}
}
}