feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -17,6 +17,7 @@ use think\exception\HttpException;
|
||||
use think\facade\Config;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use think\facade\Cache;
|
||||
use ywxapp\exception\AddonException;
|
||||
use ywxapp\service\AddonService;
|
||||
use ywxapp\service\AddonDevService;
|
||||
@@ -45,6 +46,8 @@ class Addons extends BackendBase
|
||||
if ($this->request->isAjax()) {
|
||||
$results = scandir(ADDON_PATH);
|
||||
$list = [];
|
||||
// 市场最新版本:用于列表内「可升级」提醒(市场不可达时静默降级为空)
|
||||
$latestMap = $this->latestVersions();
|
||||
foreach ($results as $name) {
|
||||
if ($name === '.' or $name === '..' or is_file(ADDON_PATH . $name)) {
|
||||
continue;
|
||||
@@ -65,8 +68,12 @@ class Addons extends BackendBase
|
||||
$info['id'] = $info['name'];
|
||||
$info['description'] = $info['intro'] ?? '';
|
||||
$info['status'] = $info['state'] ?? 0;
|
||||
$info['hasConfig'] = is_file($addonDir . 'config.php');
|
||||
$list[] = $info;
|
||||
$info['hasConfig'] = is_file($addonDir . 'config.php');
|
||||
$latest = (string) ($latestMap[$info['name']] ?? '');
|
||||
$info['latest_version'] = $latest;
|
||||
$info['upgradable'] = $latest !== ''
|
||||
&& version_compare($latest, (string) ($info['version'] ?? '0'), '>');
|
||||
$list[] = $info;
|
||||
}
|
||||
|
||||
// 关键字搜索
|
||||
@@ -96,6 +103,46 @@ class Addons extends BackendBase
|
||||
return View::fetch('addon/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场最新版本号映射(插件标识 => 版本号),5 分钟缓存
|
||||
*
|
||||
* 用于已装列表的「可升级」提醒。市场不可达 / 未配置 / 未安装 appmall 时返回空数组,
|
||||
* 列表退化为不显示升级标记,不影响插件管理主流程。
|
||||
*/
|
||||
private function latestVersions(): array
|
||||
{
|
||||
try {
|
||||
return Cache::remember('addon_market_latest_versions', function () {
|
||||
$list = [];
|
||||
try {
|
||||
if (is_market_client()) {
|
||||
$resp = (new RemoteService())->lists([]);
|
||||
$list = $resp['data']['list'] ?? [];
|
||||
} elseif (class_exists(\app\appmall\service\MarketService::class)) {
|
||||
$list = \app\appmall\service\MarketService::instance()
|
||||
->catalog('', '', 'new', 'addon')['list'] ?? [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach ((array) $list as $it) {
|
||||
if (($it['type'] ?? 'addon') !== 'addon') {
|
||||
continue;
|
||||
}
|
||||
$n = (string) ($it['name'] ?? '');
|
||||
$v = (string) ($it['version'] ?? '');
|
||||
if ($n !== '' && $v !== '') {
|
||||
$map[$n] = $v;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}, 300);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用市场(Discuz 式远程插件市场,核心后台「插件管理」)
|
||||
*
|
||||
@@ -145,13 +192,13 @@ class Addons extends BackendBase
|
||||
} else {
|
||||
$error = '未配置市场地址(请在后台配置 ywxapp.api_url)';
|
||||
}
|
||||
} elseif (!class_exists(\addon\appmall\service\MarketService::class)) {
|
||||
} elseif (!class_exists(\app\appmall\service\MarketService::class)) {
|
||||
// 本机即中心站但未安装 appmall 插件:市场目录不可用
|
||||
$error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站';
|
||||
} else {
|
||||
// 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧)
|
||||
try {
|
||||
$r = \addon\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
|
||||
$r = \app\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
|
||||
$list = $r['list'];
|
||||
$categories = $r['categories'];
|
||||
if (empty($list)) {
|
||||
@@ -275,14 +322,14 @@ class Addons extends BackendBase
|
||||
if (is_market_client()) {
|
||||
return $this->result->error('远程模式订单存于中心站,请于会员中心申请退款');
|
||||
}
|
||||
if (!class_exists(\addon\appmall\service\MarketService::class)) {
|
||||
if (!class_exists(\app\appmall\service\MarketService::class)) {
|
||||
return $this->result->error('本机未安装 appmall 插件,无法执行退款');
|
||||
}
|
||||
try {
|
||||
\addon\appmall\service\MarketService::instance()
|
||||
\app\appmall\service\MarketService::instance()
|
||||
->operatorRefund((string) $request->post('name', ''));
|
||||
} catch (\Throwable $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
return $this->result->success([], '退款成功,授权已吊销');
|
||||
}
|
||||
@@ -303,7 +350,7 @@ class Addons extends BackendBase
|
||||
}
|
||||
$token = config('ywxapp.developer_token', '');
|
||||
if (!$token) {
|
||||
return $this->result->error('未配置开发者令牌:请在 .env 设置 DEVELOPER_TOKEN');
|
||||
return $this->result->error('未配置开发者令牌:请在 .env 设置 APPMARKET_DEV_TOKEN');
|
||||
}
|
||||
$AddonService = AddonService::instance($name);
|
||||
if (!$AddonService->isInstalled()) {
|
||||
@@ -354,9 +401,9 @@ class Addons extends BackendBase
|
||||
}
|
||||
return $this->result->error('提交到官方市场失败:' . $msg);
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +422,7 @@ class Addons extends BackendBase
|
||||
$zipFile = $AddonService->package();
|
||||
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +447,11 @@ class Addons extends BackendBase
|
||||
];
|
||||
$info = AddonService::instance()->local($file, $extend);
|
||||
} catch (AddonException $e) {
|
||||
$this->result->error(LANG($e->getMessage(), $e->getCode()));
|
||||
$this->result->error(
|
||||
LANG($e->getMessage(), $e->getCode()),
|
||||
$e->getCode() ?: 1,
|
||||
$e->getData() ?: null
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->result->error(lang($e->getMessage()));
|
||||
}
|
||||
@@ -426,14 +477,20 @@ class Addons extends BackendBase
|
||||
$extend = [
|
||||
'install_user' => session('user_id'),
|
||||
'install_ip' => Request::ip(),
|
||||
'install_time' => time()
|
||||
'install_time' => time(),
|
||||
// 中心站 Market::valid() 读的是 **uid**(不是 install_user):
|
||||
// 离线上传付费插件时若不带 uid,服务端收到 uid=0 会直接判
|
||||
// 「该插件为付费插件,请先购买」,导致后台永远装不上自己买的插件。
|
||||
// 这里的 uid 指「市场会员 ID」;后台管理员 ID 只在「本机即中心站、
|
||||
// 且管理员与市场会员同一套账号」时等价,跨机器场景请走会员登录态。
|
||||
'uid' => (int) (session('user_id') ?? 0),
|
||||
];
|
||||
$info = $AddonService->local($file, $extend);
|
||||
return $this->result->success($info, '插件安装成功');
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -447,15 +504,21 @@ class Addons extends BackendBase
|
||||
if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) {
|
||||
return json(['code' => 0, 'msg' => '插件名称格式不正确']);
|
||||
}
|
||||
// 卸载选项(前端勾选,缺省沿用服务层默认:备份 + 删除)
|
||||
// backup_data=0 不备份;delete_data=0 保留插件数据表
|
||||
$options = [
|
||||
'backup_data' => (bool) input('backup_data', 1),
|
||||
'delete_data' => (bool) input('delete_data', 1),
|
||||
];
|
||||
$AddonService = AddonService::instance($addonName);
|
||||
$result = $AddonService->uninstall();
|
||||
$result = $AddonService->uninstall($options);
|
||||
if ($result) {
|
||||
$this->result->success([], '插件卸载成功');
|
||||
} else {
|
||||
$this->result->error('插件卸载失败');
|
||||
}
|
||||
} catch (AddonException $e) {
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage());
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage());
|
||||
}
|
||||
@@ -499,7 +562,7 @@ class Addons extends BackendBase
|
||||
$zipFile = $AddonService->package();
|
||||
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,9 +597,9 @@ class Addons extends BackendBase
|
||||
$info = $AddonService->local($file, $extend);
|
||||
return $this->result->success($info, '下载并安装成功');
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,9 +625,9 @@ class Addons extends BackendBase
|
||||
'升级成功:' . ($res['from'] ?? '') . ' → ' . ($res['to'] ?? '')
|
||||
);
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,10 +648,28 @@ class Addons extends BackendBase
|
||||
$post = input('post.');
|
||||
$data = [];
|
||||
foreach ($fields as $f) {
|
||||
$n = $f['name'] ?? '';
|
||||
if ($n !== '' && array_key_exists($n, $post)) {
|
||||
$data[$n] = $post[$n];
|
||||
$n = $f['name'] ?? '';
|
||||
$type = $f['type'] ?? 'text';
|
||||
if ($n === '') {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($n, $post)) {
|
||||
$v = $post[$n];
|
||||
} else {
|
||||
// 未提交:开关视为关闭、多选视为空数组;其余字段不覆盖已有值
|
||||
if ($type === 'switch') {
|
||||
$v = 0;
|
||||
} elseif ($type === 'checkbox') {
|
||||
$v = [];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// array 型:textarea 的「键=值」多行文本转数组存储
|
||||
if ($type === 'array' && is_string($v)) {
|
||||
$v = self::parseKvText($v);
|
||||
}
|
||||
$data[$n] = $v;
|
||||
}
|
||||
AddonService::config($addon, $data);
|
||||
AddonService::clearConfigCache($addon);
|
||||
@@ -596,8 +677,30 @@ class Addons extends BackendBase
|
||||
}
|
||||
$saved = AddonService::config($addon);
|
||||
foreach ($fields as &$f) {
|
||||
if (isset($saved[$f['name']])) {
|
||||
$f['value'] = $saved[$f['name']];
|
||||
$n = $f['name'] ?? '';
|
||||
$type = $f['type'] ?? 'text';
|
||||
if ($n !== '' && isset($saved[$n])) {
|
||||
$val = $saved[$n];
|
||||
// array 型:数组还原成「键=值」文本供 textarea 编辑
|
||||
if ($type === 'array' && is_array($val)) {
|
||||
$val = implode(PHP_EOL, array_map(
|
||||
function ($k, $v) {
|
||||
return $k . '=' . (is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
|
||||
},
|
||||
array_keys($val),
|
||||
$val
|
||||
));
|
||||
}
|
||||
$f['value'] = $val;
|
||||
}
|
||||
// 缺省值兜底:无已存值时用 config.php 的 default,避免模板取到未定义变量
|
||||
if (! isset($f['value'])) {
|
||||
$f['value'] = $f['default'] ?? ($type === 'checkbox' ? [] : ($type === 'switch' ? 0 : ''));
|
||||
}
|
||||
// options 容错:正则化写法。select/radio/checkbox 的 options 既可以是数组,
|
||||
// 也可以写字符串('0:否,1:是' 或 '北京,上海'),省得插件作者写一堆嵌套数组
|
||||
if (isset($f['options'])) {
|
||||
$f['options'] = self::normalizeOptions($f['options']);
|
||||
}
|
||||
}
|
||||
unset($f);
|
||||
@@ -605,6 +708,61 @@ class Addons extends BackendBase
|
||||
return View::fetch('addon/setting');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「键=值」多行文本为数组(array 型插件配置用)
|
||||
*
|
||||
* 每行一条:`key=value` 写入关联键,无等号的整行作为列表项。
|
||||
*/
|
||||
private static function parseKvText(string $text): array
|
||||
{
|
||||
$arr = [];
|
||||
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') !== false) {
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$arr[trim($k)] = trim($v);
|
||||
} else {
|
||||
$arr[] = $line;
|
||||
}
|
||||
}
|
||||
return $arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 select/radio/checkbox 的 options
|
||||
*
|
||||
* 支持三种写法(最终统一成 值=>标题 数组):
|
||||
* [1 => '开启', 0 => '关闭'] 数组(原样返回)
|
||||
* '0:关闭,1:开启' 键值对字符串
|
||||
* '北京,上海' 纯列表(值与标题相同)
|
||||
*/
|
||||
private static function normalizeOptions($options): array
|
||||
{
|
||||
if (is_array($options)) {
|
||||
return $options;
|
||||
}
|
||||
if (! is_string($options) || trim($options) === '') {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode(',', $options) as $item) {
|
||||
$item = trim($item);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($item, ':')) {
|
||||
[$k, $v] = explode(':', $item, 2);
|
||||
$out[trim($k)] = trim($v);
|
||||
} else {
|
||||
$out[$item] = $item;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ==================== 开发模式:插件设计器(抄 Discuz!) ====================
|
||||
|
||||
/**
|
||||
@@ -645,7 +803,7 @@ class Addons extends BackendBase
|
||||
$info = AddonDevService::instance($name)->createSkeleton($meta);
|
||||
return $this->result->success($info, '插件骨架创建成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,7 +823,7 @@ class Addons extends BackendBase
|
||||
$data = AddonDevService::instance($name)->readAll();
|
||||
return $this->result->success($data);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +869,7 @@ class Addons extends BackendBase
|
||||
}
|
||||
return $this->result->success([], '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +892,7 @@ class Addons extends BackendBase
|
||||
$rel = ltrim(str_replace(ADDON_PATH . $name . DIRECTORY_SEPARATOR, '', $file), DIRECTORY_SEPARATOR);
|
||||
return $this->result->success(['file' => $rel], '生成成功:' . $rel);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +912,7 @@ class Addons extends BackendBase
|
||||
$info = AddonDevService::instance($name)->developInstall();
|
||||
return $this->result->success($info, '开发模式安装成功,菜单已注入并启用');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +932,7 @@ class Addons extends BackendBase
|
||||
AddonDevService::instance($name)->remove();
|
||||
return $this->result->success([], '插件目录已删除');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user