/... → 部署到 /templates// * - static/... → 部署到 /public/static/templates// * * 模板不携带 DB 迁移,安装即文件落地;与插件(Addon)的 importsql 机制解耦。 */ namespace ywxapp\library; use ZipArchive; class TemplateInstaller { /** * 从 zip 部署模板到项目。 * * @param string $zipPath 模板包 zip 绝对路径 * @param string $rootDir 项目根目录(含 templates/ 与 public/) * @return array 部署后的清单 ['name','version','target_addon','tpl_dir','static_dir'] * @throws \RuntimeException */ public static function install(string $zipPath, string $rootDir): array { if (!is_file($zipPath)) { throw new \RuntimeException("模板包不存在: {$zipPath}"); } if (!class_exists('ZipArchive')) { throw new \RuntimeException('ZipArchive 扩展不可用'); } $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { throw new \RuntimeException("无法打开模板包: {$zipPath}"); } // 先探测模板名(templates// 前缀) $name = ''; for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); if (preg_match('#^templates/([^/]+)/#', $entry, $m)) { $name = $m[1]; break; } } if ($name === '') { $zip->close(); throw new \RuntimeException('zip 中未找到 templates// 结构,可能不是合法模板包'); } // Zip Slip 防护第一道闸:模板名必须是「纯目录名」。 // 原实现只要求匹配 templates//,而 [<^/]+> 可以是 '..', // 于是 templates/../../public/x 也能命中,后面拼路径就直接越出了安装目录。 if (! preg_match('/^[A-Za-z0-9_\-]{1,64}$/', $name)) { $zip->close(); throw new \RuntimeException("模板名不合法(只允许字母数字与 - _):{$name}"); } $tplTarget = rtrim($rootDir, '/\\') . '/templates/' . $name . '/'; $staticTarget = rtrim($rootDir, '/\\') . '/public/static/templates/' . $name . '/'; for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); if ($entry === '' || $entry === null) { continue; } $prefix = 'templates/' . $name . '/'; if (strpos($entry, $prefix) === 0) { $rel = substr($entry, strlen($prefix)); self::assertSafeRelPath($rel, $entry); self::writeEntry($zip, $entry, $tplTarget . $rel, $tplTarget); } elseif (strpos($entry, 'static/') === 0) { $rel = substr($entry, strlen('static/')); if ($rel === '') { continue; } self::assertSafeRelPath($rel, $entry); self::writeEntry($zip, $entry, $staticTarget . $rel, $staticTarget); } } $zip->close(); $metaFile = $tplTarget . 'template.json'; $meta = is_file($metaFile) ? (json_decode(file_get_contents($metaFile), true) ?? []) : []; return [ 'name' => $name, 'version' => $meta['version'] ?? '', 'title' => $meta['title'] ?? $name, 'target_addon' => $meta['target_addon'] ?? [], 'tpl_dir' => $tplTarget, 'static_dir' => $staticTarget, ]; } /** * 相对路径安全性校验(Zip Slip 第一道闸) * * 任何一个条目只要可能出现目录穿越迹象就拒绝安装: * - 绝对路径(以 / 或 Windows 盘符开头) * - 含 '..' 段 * - 含控制字符或空字节 */ protected static function assertSafeRelPath(string $rel, string $entry): void { if ($rel === '' || $rel === '.' ) { return; } if (strpos($rel, "\0") !== false || preg_match('/[\x00-\x1F]/', $rel)) { throw new \RuntimeException("模板包含非法条目名:{$entry}"); } if ($rel[0] === '/' || $rel[0] === '\\' || preg_match('#^[A-Za-z]:#', $rel)) { throw new \RuntimeException("模板包含绝对路径条目:{$entry}"); } foreach (explode('/', str_replace('\\', '/', $rel)) as $seg) { if ($seg === '..') { throw new \RuntimeException("模板包含目录穿越条目(Zip Slip):{$entry}"); } } } /** * 将 zip 内单个条目写出到目标路径(目录建目录,文件写内容)。 * * @param ZipArchive $zip * @param string $entry zip 内条目名 * @param string $target 目标绝对路径 * @param string $baseDir 允许写入的根目录(Zip Slip 第二道闸) */ protected static function writeEntry(ZipArchive $zip, string $entry, string $target, string $baseDir = ''): void { if (substr($entry, -1) === '/') { // 先校验再建目录:否则非法目录已经落到磁盘上才报错 self::assertInside($target, $baseDir, $entry); if (!is_dir($target)) { @mkdir($target, 0755, true); } return; } $dir = dirname($target); // 归一化后再确认没有跑出基准目录:realpath 能解开 ../ 与符号链接 self::assertInside($dir, $baseDir, $entry); if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $content = $zip->getFromName($entry); if ($content === false) { return; } file_put_contents($target, $content); } /** * 把一个(可能还不存在的)绝对路径规范化为可比较形式 * * realpath() 对不存在的路径返回 false,而安装时目标目录往往尚未创建, * 因此逐级向上找到第一个真实存在的祖先,再把未存在的尾部拼回去,同时折叠 `.` / `..`。 */ protected static function normalizeAbs(string $abs): string { $abs = str_replace('\\', '/', rtrim($abs, '/')); $real = realpath($abs); if ($real !== false) { return str_replace('\\', '/', rtrim($real, '/')); } $tail = []; $cur = $abs; $real = false; while (true) { $pos = strrpos($cur, '/'); if ($pos === false) { break; } $tail[] = substr($cur, $pos + 1); $cur = substr($cur, 0, $pos); if ($cur === '') { $cur = '/'; } $real = realpath($cur); if ($real !== false) { break; } // Windows 盘符根 / POSIX 根,再往上没有了 if (strlen($cur) <= 3) { break; } } if ($real === false) { return $abs; } $out = str_replace('\\', '/', rtrim($real, '/')); foreach (array_reverse($tail) as $seg) { $out .= '/' . $seg; } // 折叠 . 与 .. $parts = []; foreach (explode('/', $out) as $seg) { if ($seg === '.' || $seg === '') { continue; } if ($seg === '..') { array_pop($parts); continue; } $parts[] = $seg; } return '/' . implode('/', $parts); } /** * 确认 $path(文件或目录)确实位于 $baseDir 之内 */ protected static function assertInside(string $path, string $baseDir, string $entry): void { if ($baseDir === '') { return; } $target = self::normalizeAbs($path); $base = self::normalizeAbs($baseDir); if (strcasecmp($target, $base) !== 0 && stripos($target . '/', $base . '/') !== 0) { throw new \RuntimeException("模板包试图写入安装目录之外的路径(Zip Slip):{$entry}"); } } }