安全(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(市场 / 开发者中心 / 审核 / 分润)
206 lines
7.6 KiB
PHP
206 lines
7.6 KiB
PHP
<?php
|
||
/**
|
||
* 一次性脚本:把本地插件打包成「非 Web 目录」下的市场 zip,并生成 wxapp_appmall_addon_list 测试记录 SQL。
|
||
* 产物(zip + sql)统一输出到 build/addons/ 与 build/sql/(非 Web 可访问目录)。
|
||
*
|
||
* 与 ywxapp\service\AddonService::package() 的打包结构保持一致:
|
||
* - addon/<name>/ 目录内容 -> zip 根
|
||
* - public/static/addon/<name>/ 静态资源 -> zip 的 static/ 前缀(缺失时回退历史 public/static/<name>/)
|
||
* 客户端 download()/local() 解压时按此结构还原。
|
||
*
|
||
* 用法:
|
||
* php scripts/package_addon.php <addon> [version | major|minor|patch] [title] [author] [description] [price]
|
||
* - version 缺省时按 patch 自动递增 addons/<addon>/info.php 的版本号并写回;可显式传 x.y.z 或 major/minor/patch
|
||
*
|
||
* 例:
|
||
* php scripts/package_addon.php appmall # 自动 1.0.0 -> 1.0.1
|
||
* php scripts/package_addon.php appmall minor # 1.0.0 -> 1.1.0
|
||
* php scripts/package_addon.php mqttbroker "MQTT代理" "" "基于 Workerman 自研 MQTT Broker" 0
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR; // d:/wwwroot/ywxapp_dev/
|
||
|
||
$addon = $argv[1] ?? 'mqttbroker';
|
||
|
||
// 解析可选版本参数(位于 $argv[2]);若是版本格式则视为版本参数,其余左移一位
|
||
$verArg = $argv[2] ?? '';
|
||
$isVerArg = ($verArg !== '' && (preg_match('/^\d+\.\d+\.\d+$/', $verArg) || in_array($verArg, ['major', 'minor', 'patch'], true)));
|
||
if ($isVerArg) {
|
||
$argv[2] = $argv[3] ?? '';
|
||
$argv[3] = $argv[4] ?? '';
|
||
$argv[4] = $argv[5] ?? '';
|
||
$argv[5] = $argv[6] ?? '';
|
||
}
|
||
|
||
$price = $argv[5] ?? '0.00';
|
||
|
||
$addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($addonDir)) {
|
||
fwrite(STDERR, "插件目录不存在: {$addonDir}\n");
|
||
exit(1);
|
||
}
|
||
$info = include $addonDir . 'info.php';
|
||
$curVersion = $info['version'] ?? '';
|
||
if (!$curVersion) {
|
||
fwrite(STDERR, "info.php 缺少 version\n");
|
||
exit(1);
|
||
}
|
||
|
||
// ===================== 确定新版本号(支持自动递增) =====================
|
||
if ($verArg === '') {
|
||
$newVersion = bumpVersion($curVersion, 'patch');
|
||
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
|
||
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $verArg)) {
|
||
$newVersion = $verArg;
|
||
} else {
|
||
$newVersion = bumpVersion($curVersion, $verArg);
|
||
echo "[版本] 按 {$verArg} 递增: {$curVersion} -> {$newVersion}\n";
|
||
}
|
||
|
||
// 将新版本写回 info.php(保持插件元数据与产出一致,下次打包在此基础上再 +1)
|
||
writeAddonVersion($addonDir . 'info.php', $newVersion);
|
||
echo "[版本] 已写回 addon/{$addon}/info.php: {$newVersion}\n";
|
||
$version = $newVersion;
|
||
|
||
// 标题/作者/简介:命令行参数优先,缺省则从 info.php 读取,保持与插件元数据一致
|
||
$title = $argv[2] ?? ($info['title'] ?? '');
|
||
$author = $argv[3] ?? ($info['author'] ?? '');
|
||
$description = $argv[4] ?? ($info['intro'] ?? '');
|
||
|
||
// 输出到「非 Web 可访问」目录(build/ 不在站点根 public/ 下)
|
||
$marketDir = $root . 'build' . DIRECTORY_SEPARATOR . 'addons' . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($marketDir)) {
|
||
mkdir($marketDir, 0755, true);
|
||
}
|
||
$zipFile = $marketDir . $addon . '-' . $version . '.zip';
|
||
|
||
// 静态资源目录(可选):统一 public/static/addon/<name>/,回退历史 public/static/<name>/
|
||
$staticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
|
||
. 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
|
||
$legacyStaticDir = $root . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR
|
||
. $addon . DIRECTORY_SEPARATOR;
|
||
|
||
if (!class_exists('ZipArchive')) {
|
||
fwrite(STDERR, "ZipArchive 扩展不可用\n");
|
||
exit(1);
|
||
}
|
||
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||
fwrite(STDERR, "无法创建 zip: {$zipFile}\n");
|
||
exit(1);
|
||
}
|
||
addDirToZip($zip, $addonDir, '');
|
||
if (is_dir($staticDir)) {
|
||
addDirToZip($zip, $staticDir, 'static/');
|
||
} elseif (is_dir($legacyStaticDir)) {
|
||
fwrite(STDOUT, "提示:静态资源仍在历史路径 public/static/{$addon}/,已按新口径打包;"
|
||
. "建议迁移到 public/static/addon/{$addon}/\n");
|
||
addDirToZip($zip, $legacyStaticDir, 'static/');
|
||
}
|
||
$zip->close();
|
||
|
||
// 写入包指纹(与 AddonService::package() 一致):zip 注释 + 同名 .sha256 文件,
|
||
// 供安装端 AddonService::verifyPackageIntegrity() 校验完整性与防篡改。
|
||
$zip->open($zipFile);
|
||
$zip->setArchiveComment('YWXAPP-SHA256:' . hash_file('sha256', $zipFile));
|
||
$zip->close();
|
||
@file_put_contents($zipFile . '.sha256', hash_file('sha256', $zipFile) . ' ' . basename($zipFile) . PHP_EOL);
|
||
|
||
$hash = hash_file('sha256', $zipFile);
|
||
$size = filesize($zipFile);
|
||
$absPath = realpath($zipFile);
|
||
|
||
// SQL 转义(生成供人工执行的 INSERT)
|
||
$esc = static function (string $v): string {
|
||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||
};
|
||
|
||
$sql = "INSERT INTO `wxapp_appmall_addon_list` "
|
||
. "(`name`,`title`,`version`,`price`,`author`,`description`,`logo`,`file_path`,`file_hash`,`status`,`create_at`,`update_at`) VALUES ("
|
||
. "'" . $esc($addon) . "',"
|
||
. "'" . $esc($title) . "',"
|
||
. "'" . $esc($version) . "',"
|
||
. floatval($price) . ","
|
||
. "'" . $esc($author) . "',"
|
||
. "'" . $esc($description) . "',"
|
||
. "'',"
|
||
. "'" . $esc($absPath) . "',"
|
||
. "'" . $hash . "',"
|
||
. "1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());";
|
||
|
||
$sqlDir = $root . 'build' . DIRECTORY_SEPARATOR . 'sql' . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($sqlDir)) {
|
||
mkdir($sqlDir, 0755, true);
|
||
}
|
||
$sqlFile = $sqlDir . $addon . '-' . $version . '-appmall_addon_list.sql';
|
||
file_put_contents($sqlFile, "-- 插件市场测试记录(由 scripts/package_addon.php 生成)\n-- zip: {$absPath}\n-- sha256: {$hash}\n" . $sql . "\n");
|
||
|
||
echo "=== 打包完成 ===\n";
|
||
echo "zip : {$absPath}\n";
|
||
echo "size : {$size} bytes\n";
|
||
echo "sha256: {$hash}\n\n";
|
||
echo "=== 测试记录 SQL(已写入 {$sqlFile})===\n";
|
||
echo $sql . "\n";
|
||
|
||
/**
|
||
* 在 x.y.z 基础上递增指定段(major/minor/patch)
|
||
*/
|
||
function bumpVersion(string $ver, string $level = 'patch'): string
|
||
{
|
||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)$/', $ver, $m)) {
|
||
return $ver;
|
||
}
|
||
$major = (int) $m[1];
|
||
$minor = (int) $m[2];
|
||
$patch = (int) $m[3];
|
||
switch ($level) {
|
||
case 'major':
|
||
$major++; $minor = 0; $patch = 0; break;
|
||
case 'minor':
|
||
$minor++; $patch = 0; break;
|
||
case 'patch':
|
||
default:
|
||
$patch++; break;
|
||
}
|
||
return $major . '.' . $minor . '.' . $patch;
|
||
}
|
||
|
||
/**
|
||
* 将新版本号写回插件 info.php
|
||
*/
|
||
function writeAddonVersion(string $infoFile, string $ver): void
|
||
{
|
||
if (!is_file($infoFile)) {
|
||
return;
|
||
}
|
||
$c = file_get_contents($infoFile);
|
||
$c = preg_replace(
|
||
"/(['\"])version['\"]\s*=>\s*['\"][^'\"]*['\"]/",
|
||
"'version' => '" . $ver . "'",
|
||
$c
|
||
);
|
||
file_put_contents($infoFile, $c);
|
||
}
|
||
|
||
/**
|
||
* 将目录递归加入 zip(结构对齐 AddonService::addDirectoryToZip)
|
||
*/
|
||
function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
|
||
{
|
||
$files = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||
RecursiveIteratorIterator::SELF_FIRST
|
||
);
|
||
foreach ($files as $file) {
|
||
$filePath = $file->getRealPath();
|
||
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen($dir))), '/');
|
||
if ($file->isDir()) {
|
||
$zip->addEmptyDir($prefix . $rel);
|
||
} else {
|
||
$zip->addFile($filePath, $prefix . $rel);
|
||
}
|
||
}
|
||
}
|