Files
YwxAppThink/scripts/package_framework.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

519 lines
23 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
/**
* 一键打包「主框架整站升级包」(整包 + 增量补丁包)以及「完整项目安装包」。
*
* 与 ywxapp\service\FrameworkService::apply() 的约定一致:
* - 整包(full):zip 内含整站代码目录(app/config/extend/ywxapp/public/route 等),
* 解压根即项目根目录,apply 会按包内顶层目录识别为「整站包」并整体覆盖。
* - 补丁(patch):zip 内仅含相对基础版本的变更文件(路径相对项目根),
* 并附 delete.list(路径相对项目根,列出待删除的下线文件)。
*
* 安全:打包时排除运行/数据/客户端配置目录——runtime/vendor/data/.git、
* public/uploads(用户上传)、config/database.php、config/ywxapp.php(库与升级服务器配置)、
* .env、install.lock,避免升级覆盖客户机本地配置与数据。
* 另:本脚本只打包「框架/整站骨架」,**不包含插件、模板皮肤与中心站专用模块**:
* - 插件代码位于 addon/(不在打包目录内);
* - app/appmall/ 是「中心站专用」的应用市场服务端,只在总站部署;站长站(客户机)仅需
* ywxapp/ 内的客户端服务 + config('appmall.*') 调中心站远程 API 购买/安装插件,
* 故整包/补丁/安装包一律排除(见下方 $excludeAppRel)。
* public/static/ 采用白名单制——只打包 backend/frontend/member/common 四个核心
* 静态目录,其余(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤
* public/static/templates 等)一律从整包/补丁/安装包中自动剔除。
* 插件请改用 scripts/package_addon.php、模板请改用 scripts/package_template.php
* 等独立打包流程分发。
*
* 产物输出到 build/(非 Web 可访问目录):
* - build/framework/ywxapp-<version>.zip 整包(整站,用于升级)
* - build/framework/ywxapp-<version>-patch.zip 补丁(from_version = 基础版本,用于升级)
* - build/framework/ywxapp-<version>-install.zip 完整项目安装包(白名单:app/config/extend/public/ywxapp/route
* + 根文件;不含插件、不含真实 .env/database.php/ywxapp.php,供新站首次部署)
* 一次运行同时产出上述三个文件;插件仍走 scripts/package_all_addons.php 独立分发;
* appmall(中心站专用模块)由总站随仓库自行部署,不进任何框架产物。
*
* 数据库升级(DDL/DML 变更)怎么办:
* - 若本次升级涉及数据库结构/数据变更,把 SQL 放到仓库的 ywxapp/upgrade.sql
* (整站包含 ywxapp/ 目录,会自动打进包;解压后位于 项目根/ywxapp/upgrade.sql)。
* - FrameworkService::apply() 解压完成后会自动检测并执行该 SQL(按语句拆分,
* 忽略 -- 行注释与 /* *\/ 块注释),并用 build/framework/sql_applied.json
* 记录已执行版本,幂等(重复升级不重复执行)。执行失败会抛异常由升级流程提示。
* - 仅放一个 ywxapp/upgrade.sql 即可;如需按版本分别演进,可在每次发布前更新其内容为
* 该版本相对上一版的增量 SQL。
*
* 用法:
* php scripts/package_framework.php [newVersion | major|minor|patch] [baseVersion]
* - newVersion 选填:
* x.y.z 显式指定新版本(并写回 config/ywxapp.php)
* major|minor|patch 在 config/ywxapp.php 当前版本基础上递增对应段
* 留空 默认按 patch 自动递增(并写回 config/ywxapp.php)
* - baseVersion 选填,默认读取 config/ywxapp.php 的当前 version(作为升级起点)
*/
declare(strict_types=1);
$root = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$cfgFile = $root . 'config' . DIRECTORY_SEPARATOR . 'ywxapp.php';
$curVersion = readFrameworkVersion($cfgFile);
$arg1 = $argv[1] ?? '';
// ===================== 确定新版本号(支持自动递增) =====================
if ($arg1 === '') {
// 留空:默认按 patch 自动 +1
if ($curVersion === '') {
fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n");
exit(1);
}
$newVersion = bumpVersion($curVersion, 'patch');
echo "[版本] 未指定版本号,按 patch 自动递增: {$curVersion} -> {$newVersion}\n";
} elseif (preg_match('/^\d+\.\d+\.\d+$/', $arg1)) {
// 显式 x.y.z
$newVersion = $arg1;
} elseif (in_array($arg1, ['major', 'minor', 'patch'], true)) {
// 按段递增
if ($curVersion === '') {
fwrite(STDERR, "未指定版本号,且无法从 config/ywxapp.php 读取当前版本,请显式传入 x.y.z\n");
exit(1);
}
$newVersion = bumpVersion($curVersion, $arg1);
echo "[版本] 按 {$arg1} 递增: {$curVersion} -> {$newVersion}\n";
} else {
fwrite(STDERR, "用法: php scripts/package_framework.php [newVersion x.y.z | major|minor|patch] [baseVersion x.y.z]\n");
exit(1);
}
// ===================== 回归校验:版本号写入不能写坏配置 =====================
// 必须在打包前拦截:一旦 FrameworkService::updateConfigVersion 回归成旧的
// '$1' . $version 写法,真实升级会把 'version' => '1.0.0' 写成 .0.1',损坏配置文件。
regressionCheckConfigVersion($root, $newVersion);
// baseVersion 缺省取 config 当前版本(升级起点,即递增前的值)
$baseVersion = $argv[2] ?? '';
if ($baseVersion === '') {
$baseVersion = $curVersion !== '' ? $curVersion : $newVersion;
}
// 将新版本写回 config/ywxapp.php(保持仓库版本与产出一致,下次打包在此基础上再 +1)
writeFrameworkVersion($cfgFile, $newVersion);
echo "[版本] 已写回 config/ywxapp.php: {$newVersion}\n";
if (!class_exists('ZipArchive')) {
fwrite(STDERR, "ZipArchive 扩展不可用\n");
exit(1);
}
// 整站包含的目录与根文件(注意:addons/ 不在此列,插件代码本身不打进框架包)
$includeDirs = ['app', 'config', 'extend', 'ywxapp', 'public', 'route'];
// appmall 是「中心站专用」模块(应用市场服务端):只在总站部署,绝不随框架分发给站长。
// 站长站(客户机)只需 ywxapp/ 内的客户端服务 + config('appmall.*') 调用中心站远程 API
// 购买/安装插件,不需要市场服务端代码。故整包、补丁包、安装包一律排除 app/appmall/。
$excludeAppRel = ['appmall'];
// public/static/ 白名单:框架包只保留核心静态目录 backend/frontend/member/common,
// 其余子目录(插件静态 public/static/<插件名>/ 如 haonav/download、模板皮肤
// public/static/templates 等)一律排除,由 package_addon.php / package_template.php
// 独立打包分发。动态扫描 static 目录得到排除列表,比按 addon/ 名扫描更彻底。
$staticAllowedDirs = ['backend', 'frontend', 'member', 'common'];
$excludeStaticRel = [];
$staticRoot = $root . 'public' . DIRECTORY_SEPARATOR . 'static';
if (is_dir($staticRoot)) {
foreach (array_diff(scandir($staticRoot), ['.', '..']) as $entry) {
if (is_dir($staticRoot . DIRECTORY_SEPARATOR . $entry) && !in_array($entry, $staticAllowedDirs, true)) {
$excludeStaticRel[] = 'public/static/' . $entry;
}
}
}
// 各目录打包时的排除项(保护客户机本地配置/数据,以及排除插件/模板静态资源)
$dirExclude = [
'public' => ['uploads', 'addons'],
'config' => ['database.php', 'ywxapp.php'],
];
// 各目录内的相对路径前缀排除(如 app/appmall、public/static/<插件名>、public/static/templates)
$dirExcludeRel = [
'app' => $excludeAppRel,
'public' => array_map(static function (string $r) {
return substr($r, strlen('public/')); // 变为 'static/haonav'、'static/templates' 等
}, $excludeStaticRel),
];
$includeFiles = ['think', 'composer.json', 'README.md', 'LICENSE.txt'];
// 受保护、绝不允许进包的路径(相对项目根),用于整包/补丁过滤
$protectedRel = static function (string $rel) use ($excludeStaticRel): bool {
$rel = ltrim($rel, '/');
$segs = explode('/', $rel);
$first = $segs[0] ?? '';
// 插件/模板静态资源按「目录前缀」剔除(public/static/<非白名单目录>/...),
// 与整包 addDirToZip 的 $excludeRel 前缀匹配保持一致;不能只用 in_array
// 精确匹配,否则补丁里具体的文件路径(public/static/haonav/css/nav.css)
// 匹配不上目录条目(public/static/haonav)而漏进包。
$excludedStatic = false;
foreach ($excludeStaticRel as $ex) {
$ex = trim($ex, '/');
if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) {
$excludedStatic = true;
break;
}
}
if (
in_array($first, ['runtime', 'vendor', 'data'], true)
|| $rel === '.env' || $rel === 'install.lock'
|| ($first === 'public' && ($segs[1] ?? '') === 'uploads')
|| ($first === 'config' && in_array($segs[1] ?? '', ['database.php', 'ywxapp.php'], true))
|| strpos($rel, 'addons/') === 0 // 插件代码(双保险,addons/ 本就不在 includeDirs)
|| ($first === 'app' && ($segs[1] ?? '') === 'appmall') // appmall 中心站专用模块(不随框架分发)
|| $excludedStatic
) { // 插件静态资源 + 模板/皮肤静态
return true;
}
return false;
};
$outDir = $root . 'build' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR;
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
// ===================== 整包 =====================
$fullZipPath = $outDir . 'ywxapp-' . $newVersion . '.zip';
$full = new ZipArchive();
if ($full->open($fullZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "无法创建整包: {$fullZipPath}\n");
exit(1);
}
foreach ($includeDirs as $d) {
$p = $root . $d;
if (is_dir($p)) {
$excl = $dirExclude[$d] ?? [];
$exclRel = $dirExcludeRel[$d] ?? [];
addDirToZip($full, $p, $d . '/', $excl, $exclRel);
}
}
foreach ($includeFiles as $f) {
$p = $root . $f;
if (is_file($p)) {
$full->addFile($p, $f);
}
}
$full->close();
$fullHash = hash_file('sha256', $fullZipPath);
$fullSize = filesize($fullZipPath);
// ===================== 补丁包 =====================
// 基于 git 工作区相对 HEAD(=基础版本) 的变更(跨整站代码目录):
// - 已跟踪文件的增/改/删 用 git diff --name-status HEAD
// - 未跟踪的新文件 用 git ls-files --others
$deleteList = [];
$addedRel = [];
foreach ($includeDirs as $d) {
$diff = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' diff --name-status HEAD -- ' . escapeshellarg($d . '/'));
foreach (explode("\n", $diff) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$parts = preg_split('/\t/', $line);
$status = $parts[0];
$path = $parts[count($parts) - 1]; // 重命名取新路径
$path = str_replace('\\', '/', trim($path, '"'));
if ($protectedRel($path)) {
continue;
}
$code = $status[0];
if ($code === 'D') {
$deleteList[] = $path;
} elseif ($code === 'A' || $code === 'M') {
if (is_file($root . $path)) {
$addedRel[] = $path;
}
}
}
$untracked = (string) @shell_exec('git -C ' . escapeshellarg($root) . ' ls-files --others --exclude-standard -- ' . escapeshellarg($d . '/'));
foreach (explode("\n", $untracked) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$path = str_replace('\\', '/', trim($line, '"'));
if ($protectedRel($path)) {
continue;
}
if (is_file($root . $path) && !in_array($path, $addedRel, true)) {
$addedRel[] = $path;
}
}
}
$patchZipPath = $outDir . 'ywxapp-' . $newVersion . '-patch.zip';
$patch = new ZipArchive();
if ($patch->open($patchZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "无法创建补丁包: {$patchZipPath}\n");
exit(1);
}
foreach ($addedRel as $rel) {
$patch->addFile($root . $rel, $rel);
}
if ($deleteList) {
$patch->addFromString('delete.list', implode("\n", $deleteList) . "\n");
}
// 补丁元信息(apply 仅消费 delete.list / upgrade.json)
$patch->addFromString('upgrade.json', json_encode([
'from_version' => $baseVersion,
'version' => $newVersion,
'delete' => $deleteList,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
$patch->close();
$patchHash = hash_file('sha256', $patchZipPath);
$patchSize = filesize($patchZipPath);
// ===================== 完整项目安装包(白名单,供新站首次部署) =====================
$installDir = $outDir; // 三个文件统一输出到 build/framework/
if (!is_dir($installDir)) {
mkdir($installDir, 0755, true);
}
$installZipPath = $installDir . 'ywxapp-' . $newVersion . '-install.zip';
$installZip = new ZipArchive();
if ($installZip->open($installZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "无法创建安装包: {$installZipPath}\n");
exit(1);
}
// 白名单:仅下列根级条目进包(不含插件 addons/、不含运行时/依赖/密钥)
$installDirs = ['app', 'config', 'extend', 'public', 'ywxapp', 'route'];
$installFiles = ['404.html', 'composer.json', 'think', '.env.production.example'];
// 各目录额外排除(按 basename):config 排除本地库/升级服务器配置;public 排除用户上传
$installDirExcludes = [
'app' => ['appmall'], // 中心站专用模块,不进安装包(站长站无需市场服务端)
'config' => ['database.php', 'ywxapp.php'],
'public' => ['uploads', 'addons'],
];
$installGlobalExcludes = ['runtime', 'vendor', '.git', 'node_modules', 'cache', 'install.lock', '.env'];
foreach ($installDirs as $d) {
$exNames = array_merge($installGlobalExcludes, $installDirExcludes[$d] ?? []);
// public 目录额外排除 static 白名单(backend/frontend/member/common)之外的目录,
// 保证安装包不含任何插件/模板静态产物。
// 注意 addDirToZip 的 $excludeRel 是「相对被打包目录」前缀(public 下即 static/xxx),需去掉 public/ 段
$exRel = ($d === 'public') ? array_map(fn($p) => substr($p, strlen('public/')), $excludeStaticRel) : [];
addDirToZip($installZip, $root . $d, $d . '/', $exNames, $exRel);
}
foreach ($installFiles as $f) {
$abs = $root . $f;
if (!file_exists($abs)) {
echo " (安装包) 跳过不存在: {$f}\n";
continue;
}
// .env.production.example 以 .env 之名打进包(占位模板,无真实密钥)
if ($f === '.env.production.example') {
$installZip->addFile($abs, '.env');
} else {
$installZip->addFile($abs, $f);
}
}
$installZip->close();
$installHash = hash_file('sha256', $installZipPath);
$installSize = filesize($installZipPath);
// ===================== 发布包签名(防篡改 / 防伪造来源) =====================
// apply() 会把包直接解压覆盖整站,因此发布包必须带我方私钥签名;
// 客户机用随版本发布的公钥先验签再安装(见 ywxapp\service\PackageVerifier)。
// 私钥缺失时只警告不中断:本机自测允许出未签名包,**对外发布前必须带签名**。
require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'package_signature.php';
$signKey = pkResolvePrivateKey($root);
$packages = [
'整包' => ['path' => $fullZipPath, 'hash' => &$fullHash, 'size' => &$fullSize, 'sig' => null],
'补丁' => ['path' => $patchZipPath, 'hash' => &$patchHash, 'size' => &$patchSize, 'sig' => null],
'安装包' => ['path' => $installZipPath, 'hash' => &$installHash, 'size' => &$installSize, 'sig' => null],
];
foreach ($packages as $label => &$pkg) {
try {
$pkg['sig'] = pkSignPackage($pkg['path'], $newVersion, $signKey);
} catch (Throwable $e) {
fwrite(STDERR, "[签名失败] {$label}: " . $e->getMessage() . "\n");
}
// 签名会改写 zip(追加 signature.json),故哈希与体积以签名后为准
$pkg['hash'] = hash_file('sha256', $pkg['path']);
$pkg['size'] = filesize($pkg['path']);
}
unset($pkg);
// ===================== 输出 =====================
echo "=== 打包完成 (base={$baseVersion} -> new={$newVersion}, 整站) ===\n\n";
echo "[整包] {$fullZipPath}\n";
echo " size : {$fullSize} bytes\n";
echo " sha256: {$fullHash}\n";
echo " sign : " . pkSignLabel($packages['整包']['sig']) . "\n\n";
echo "[补丁] {$patchZipPath}\n";
echo " size : {$patchSize} bytes\n";
echo " sha256: {$patchHash}\n";
echo " sign : " . pkSignLabel($packages['补丁']['sig']) . "\n";
echo " changed files (" . count($addedRel) . "):\n";
foreach ($addedRel as $r) {
echo " + {$r}\n";
}
echo " deleted files (" . count($deleteList) . "):\n";
foreach ($deleteList as $r) {
echo " - {$r}\n";
}
echo "\n[安装包] {$installZipPath}\n";
echo " size : {$installSize} bytes\n";
echo " sha256: {$installHash}\n";
echo " sign : " . pkSignLabel($packages['安装包']['sig']) . "\n";
echo " 说明 : 新站首次部署用,不含插件/addons、不含 appmall(中心站专用模块)、不含真实 .env·database.php·ywxapp.php\n";
/**
* 回归校验:updateConfigVersion 必须用 '${1}' 写法写入版本号。
* 否则当版本号含数字(如 1.0.1)时,'$1' 后接数字会被 PHP 当成第 11 个捕获组,
* 把 'version' => '1.0.0' 写成 .0.1',损坏配置文件导致应用无法启动。
*/
function regressionCheckConfigVersion(string $root, string $newVersion): void
{
$svcFile = $root . 'ywxapp' . DIRECTORY_SEPARATOR . 'service' . DIRECTORY_SEPARATOR . 'FrameworkService.php';
if (!is_file($svcFile)) {
echo "[回归校验] 跳过:未找到 FrameworkService.php\n";
return;
}
$src = file_get_contents($svcFile);
$bad = '\'$1\' . $version';
$good = '\'${1}\'';
// 1) 静态检查:禁止遗留 bug 写法('$1' . $version 且未使用 '${1}')
if (strpos($src, $bad) !== false && strpos($src, $good) === false) {
fwrite(
STDERR,
"[回归校验] FAIL: FrameworkService::updateConfigVersion 仍使用 bug 写法 '$1' . \$version," .
"会把版本号写坏(应改为 '\${1}' . \$version . '\${2}')。\n"
);
exit(1);
}
// 2) 运行时模拟:用修复后的替换串写入,确认版本号正确落盘格式
// 注意:真实配置行 'version' 与 => 之间有多余空格,替换后原样保留,
// 故断言用宽松匹配:版本号 token 被正确写入且 'version' 键前缀未丢失。
$sample = " 'version' => '1.0.0',\n";
$fixed = preg_replace(
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
'${1}' . $newVersion . '${2}',
$sample
);
if (strpos($fixed, "'{$newVersion}'") === false || strpos($fixed, "'version'") === false) {
fwrite(STDERR, "[回归校验] FAIL: 模拟写版本号结果异常: " . trim($fixed) . "\n");
exit(1);
}
// 3) 反向确认:bug 写法确实会写坏(仅作证明,不中断流程)
$buggy = preg_replace(
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
'$1' . $newVersion . '$2',
$sample
);
if (strpos($buggy, "'{$newVersion}'") === false) {
echo "[回归校验] OK: 已确认修复写法正确(若回归旧写法会写成: " . trim($buggy) . ")\n";
} else {
echo "[回归校验] OK: 版本号写入校验通过 (new={$newVersion})\n";
}
}
/**
* 读取 config/ywxapp.php 中的框架版本号
*/
function readFrameworkVersion(string $cfgFile): string
{
if (!is_file($cfgFile)) {
return '';
}
$c = file_get_contents($cfgFile);
if (preg_match("/['\"]version['\"]\s*=>\s*['\"]([^'\"]+)['\"]/", $c, $m)) {
return $m[1];
}
return '';
}
/**
* 在 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;
}
/**
* 将新版本号写回 config/ywxapp.php(必须用 '${1}' 安全写法,避免重写坏配置)
*/
function writeFrameworkVersion(string $cfgFile, string $ver): void
{
if (!is_file($cfgFile)) {
return;
}
$c = file_get_contents($cfgFile);
$c = preg_replace(
"/(['\"]version['\"]\s*=>\s*['\"])[^'\"]*(['\"])/",
'${1}' . $ver . '${2}',
$c
);
file_put_contents($cfgFile, $c);
}
/**
* 将目录递归加入 zip。
* - $excludeNames:按 basename 排除(如 uploads)。
* - $excludeRel:按相对路径前缀排除(相对被打包目录,如 public 传入 'static/haonav'),
* 用于排除插件静态资源等子目录。
*/
function addDirToZip(ZipArchive $zip, string $dir, string $prefix, array $excludeNames = [], array $excludeRel = []): void
{
$dir = rtrim($dir, '/\\');
$iter = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
static function ($file) use ($dir, $excludeNames, $excludeRel) {
$fp = $file->getRealPath();
$rel = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($fp, strlen($dir))), '/');
if (in_array($file->getFilename(), $excludeNames, true)) {
return false;
}
foreach ($excludeRel as $ex) {
$ex = trim($ex, '/');
if ($rel === $ex || strpos($rel . '/', $ex . '/') === 0) {
return false;
}
}
return true;
}
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter 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);
}
}
}