feat(update): 系统更新改为从 GitHub Release 拉包
12 个文件变更
+1694
-161
13172193298@163.com
| •MPHX/BL.php | +1 -1 |
| •MPHX/github_updater.php | +973 -0 |
| •admin/ajax.php | +1 -0 |
| •admin/api/bt.php | +19 -17 |
| •admin/api/gg.php | +123 -56 |
| •admin/api/update_setting.php | +51 -0 |
| •admin/update.php | +4 -1 |
| •docs/guide/changelog.md | +43 -1 |
| •templates/default/admin/update.php | +207 -61 |
| •templates/tdesign/admin/update.php | +44 -15 |
| •templates/tdesign/spa/src/admin/api/dashboard.js | +10 -0 |
| •templates/tdesign/spa/src/admin/views/UpdateView.vue | +218 -9 |
变更内容
diff --git a/MPHX/BL.php b/MPHX/BL.php
index d668bdc..c645a40 100644
--- a/MPHX/BL.php
+++ b/MPHX/BL.php
@@ -1,4 +1,4 @@
<?php
-$WEBQB='1840';
+$WEBQB='1850';
$SQLQB='1310';
?>
\ No newline at end of file
diff --git a/MPHX/github_updater.php b/MPHX/github_updater.php
new file mode 100644
index 0000000..ec45b98
--- /dev/null
+++ b/MPHX/github_updater.php
@@ -0,0 +1,973 @@
+<?php
+if (!defined('IN_CRONLITE')) exit();
+/**
+ * 系统更新:从 GitHub Release 拉包覆盖更新(V1.85 起替代自建更新服务器 check.php)
+ *
+ * 为什么这样写:更新包来自站外,配置文件 cf_up.php 里又能改仓库与镜像地址,
+ * 所以这里内置域名白名单 + 解析后 IP 校验,防止配置被人改成内网地址形成 SSRF。
+ * 只定义函数,不产生任何输出。
+ */
+
+/** 站点根目录(带尾斜杠) */
+function mnbt_updater_root()
+{
+ return defined('ROOT') ? ROOT : rtrim(str_replace('\\', '/', dirname(__DIR__)), '/') . '/';
+}
+
+/** 默认配置:仓库与镜像 */
+function mnbt_updater_defaults()
+{
+ return ['repo' => '1181469655/MNBT', 'mirrors' => ['https://gh-proxy.com/'], 'github_token' => ''];
+}
+
+/** 读 cf_up.php 的 $mn_conf(页面已 include 时直接复用,避免重复读文件) */
+function mnbt_updater_read_conf()
+{
+ if (isset($GLOBALS['mn_conf']) && is_array($GLOBALS['mn_conf'])) {
+ return $GLOBALS['mn_conf'];
+ }
+ $mn_conf = [];
+ $cf = mnbt_updater_root() . 'cf_up.php';
+ if (is_file($cf)) {
+ include $cf;
+ }
+ return is_array($mn_conf) ? $mn_conf : [];
+}
+
+/** owner/repo 规范化,非法返回空串 */
+function mnbt_updater_norm_repo($repo)
+{
+ $repo = trim((string)$repo);
+ $repo = preg_replace('#^https?://github\.com/#i', '', $repo);
+ $repo = trim($repo, " \t\n\r/");
+ if (preg_match('#^([A-Za-z0-9._\-]{1,60})/([A-Za-z0-9._\-]{1,80})$#', $repo, $m)) {
+ // 仓库名带 .git 后缀时剥掉,GitHub API 不接受
+ return $m[1] . '/' . preg_replace('#\.git$#', '', $m[2]);
+ }
+ return '';
+}
+
+/** 镜像地址规范化:支持只填域名,统一补协议与尾斜杠;非法返回空串 */
+function mnbt_updater_norm_mirror($mirror)
+{
+ $mirror = trim((string)$mirror);
+ if ($mirror === '') return '';
+ if (!preg_match('#^https?://#i', $mirror)) {
+ if (!preg_match('#^[A-Za-z0-9.\-]{3,120}$#', $mirror)) return '';
+ $mirror = 'https://' . $mirror;
+ }
+ $mirror = rtrim($mirror, '/') . '/';
+ // 双引号写回 cf_up.php,出现引号/反斜杠/美元符会破坏配置文件
+ if (!preg_match('#^https?://[A-Za-z0-9.\-:/_]+$#', $mirror)) return '';
+ $host = strtolower((string)parse_url($mirror, PHP_URL_HOST));
+ if ($host === '' || strpos($host, '.') === false) return '';
+ return $mirror;
+}
+
+/** 更新配置:仓库、镜像列表、Token;缺省值兜底 */
+function mnbt_updater_config()
+{
+ $mn_conf = mnbt_updater_read_conf();
+ $def = mnbt_updater_defaults();
+ $up = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
+
+ $repo = isset($up['repo']) ? mnbt_updater_norm_repo($up['repo']) : '';
+ if ($repo === '') $repo = $def['repo'];
+
+ $mirrors = [];
+ $mirrors_set = false;
+ if (isset($up['mirrors'])) {
+ // 显式配置过就按配置走,包括被清空(只走 github 直连)
+ $mirrors_set = true;
+ $raw = is_array($up['mirrors']) ? $up['mirrors'] : preg_split('/\r\n|\r|\n/', (string)$up['mirrors']);
+ foreach ($raw as $m) {
+ $n = mnbt_updater_norm_mirror($m);
+ if ($n !== '' && !in_array($n, $mirrors, true)) $mirrors[] = $n;
+ }
+ }
+ if (!$mirrors_set && !$mirrors) $mirrors = $def['mirrors'];
+
+ $token = trim((string)($up['github_token'] ?? ''));
+ if ($token !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token)) $token = '';
+
+ return ['repo' => $repo, 'mirrors' => $mirrors, 'github_token' => $token];
+}
+
+/**
+ * 回写 cf_up.php 的 update 段,其余键原样保留(参照 admin/api/repair.php 的 ary_asd 写法)
+ * @param string|null $token null 表示保持原值不变
+ */
+function mnbt_updater_save_config($repo, $mirrors, $token = null)
+{
+ $repo = mnbt_updater_norm_repo($repo);
+ if ($repo === '') return ['ok' => 0, 'error' => '仓库格式不正确,应为 owner/repo'];
+
+ $clean = [];
+ foreach ((array)$mirrors as $m) {
+ $n = mnbt_updater_norm_mirror($m);
+ if ($n !== '' && !in_array($n, $clean, true)) $clean[] = $n;
+ }
+ $token_new = null;
+ if ($token !== null) {
+ $token_new = trim((string)$token);
+ if ($token_new !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token_new)) {
+ return ['ok' => 0, 'error' => 'Token 含非法字符,只允许字母、数字与 . _ -'];
+ }
+ }
+
+ $mn_conf = mnbt_updater_read_conf();
+ $old = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
+ $mn_conf['update'] = [
+ 'repo' => $repo,
+ 'mirrors' => array_values($clean),
+ 'github_token' => $token_new === null ? (string)($old['github_token'] ?? '') : $token_new,
+ ];
+
+ $kr_sxy = ary_asd($mn_conf);
+ if ($kr_sxy === '') return ['ok' => 0, 'error' => '配置数据为空,已取消写入'];
+ $cf = mnbt_updater_root() . 'cf_up.php';
+ if (is_file($cf) && !is_writable($cf)) return ['ok' => 0, 'error' => 'cf_up.php 不可写,请检查文件权限'];
+ $wr = @file_put_contents($cf, '<?php $mn_conf=array(' . $kr_sxy . ');?>');
+ if ($wr === false) return ['ok' => 0, 'error' => '配置文件写入失败'];
+ $GLOBALS['mn_conf'] = $mn_conf;
+ return ['ok' => 1, 'error' => ''];
+}
+
+/** 下载/接口地址的 host 是否授权:GitHub 官方域 + 配置里的镜像域 */
+function mnbt_updater_host_allowed($host, $cfg)
+{
+ $host = strtolower(trim((string)$host));
+ if ($host === '') return false;
+ // github.com / api.github.com / codeload.github.com / *.githubusercontent.com(附件与源码包的跳转目标)
+ if (preg_match('#^([a-z0-9\-]+\.)*github(usercontent)?\.com$#', $host)) return true;
+ foreach ((array)$cfg['mirrors'] as $m) {
+ $mh = strtolower((string)parse_url($m, PHP_URL_HOST));
+ if ($mh !== '' && $mh === $host) return true;
+ }
+ return false;
+}
+
+/** IP 是否为公网可路由地址 */
+function mnbt_updater_ip_public($ip)
+{
+ return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+}
+
+/** 域名解析结果必须全是公网地址,否则视为内网打洞 */
+function mnbt_updater_host_public($host, &$err)
+{
+ static $memo = [];
+ $host = strtolower((string)$host);
+ if (isset($memo[$host])) {
+ // 同一次请求里会反复校验同一批地址,避免重复解析
+ if ($memo[$host] === true) return true;
+ $err = $memo[$host];
+ return false;
+ }
+ $ips = @gethostbynamel($host);
+ if (!is_array($ips)) $ips = [];
+ if (function_exists('dns_get_record')) {
+ $v6 = @dns_get_record($host, DNS_AAAA);
+ if (is_array($v6)) {
+ foreach ($v6 as $r) {
+ if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
+ }
+ }
+ }
+ if (!$ips) {
+ $err = '无法解析域名 ' . $host;
+ $memo[$host] = $err;
+ return false;
+ }
+ foreach ($ips as $ip) {
+ if (!mnbt_updater_ip_public($ip)) {
+ $err = '域名 ' . $host . ' 解析到内网或保留地址,已拒绝';
+ $memo[$host] = $err;
+ return false;
+ }
+ }
+ $memo[$host] = true;
+ return true;
+}
+
+/** 完整地址安全检查:协议 + 白名单 + 解析 IP */
+function mnbt_updater_url_safe($url, $cfg, &$err)
+{
+ $url = trim((string)$url);
+ if (!preg_match('#^https?://#i', $url)) {
+ $err = '仅允许 http/https 地址';
+ return false;
+ }
+ $host = (string)parse_url($url, PHP_URL_HOST);
+ if ($host === '') {
+ $err = '地址无效';
+ return false;
+ }
+ if (!mnbt_updater_host_allowed($host, $cfg)) {
+ $err = '未授权的下载地址:' . $host;
+ return false;
+ }
+ return mnbt_updater_host_public($host, $err);
+}
+
+/** GitHub API 单次请求(返回体量很小,可以进内存) */
+function mnbt_updater_api_once($url, $cfg, $send_token)
+{
+ $out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
+ $err = '';
+ if (!mnbt_updater_url_safe($url, $cfg, $err)) {
+ $out['error'] = $err;
+ return $out;
+ }
+ $headers = [
+ 'User-Agent: MNBT-Updater/1.85',
+ 'Accept: application/vnd.github+json',
+ ];
+ // Token 只用于提速率,发给镜像就等于把凭据交给第三方,所以只有直连才带
+ if ($send_token && $cfg['github_token'] !== '') {
+ $headers[] = 'Authorization: Bearer ' . $cfg['github_token'];
+ }
+ $ch = curl_init($url);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HEADER, false);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 20);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ $body = curl_exec($ch);
+ $cerr = curl_error($ch);
+ $out['http'] = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($body === false) {
+ $out['error'] = $cerr !== '' ? $cerr : '接口请求失败';
+ return $out;
+ }
+ if ($out['http'] < 200 || $out['http'] >= 300) {
+ $js = json_decode((string)$body, true);
+ $msg = is_array($js) && isset($js['message']) ? (string)$js['message'] : '';
+ $out['error'] = 'HTTP ' . $out['http'] . ($msg !== '' ? '(' . $msg . ')' : '');
+ return $out;
+ }
+ $js = json_decode((string)$body, true);
+ if (!is_array($js)) {
+ $out['error'] = '接口返回内容无法解析';
+ return $out;
+ }
+ $out['ok'] = 1;
+ $out['json'] = $js;
+ return $out;
+}
+
+/**
+ * 调用 GitHub API:先直连 api.github.com,失败再按镜像顺序尝试
+ * 国内环境直连 api.github.com 常常不通,但镜像可以代理它,所以这里也要有镜像兜底
+ */
+function mnbt_updater_api_get($path, $cfg)
+{
+ $out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
+ if (!function_exists('curl_init')) {
+ $out['error'] = '服务器未启用 curl 扩展';
+ return $out;
+ }
+ $direct = 'https://api.github.com' . $path;
+ $urls = [['url' => $direct, 'token' => true]];
+ foreach ($cfg['mirrors'] as $m) {
+ $urls[] = ['url' => $m . $direct, 'token' => false];
+ }
+ $errs = [];
+ foreach ($urls as $u) {
+ $r = mnbt_updater_api_once($u['url'], $cfg, $u['token']);
+ if ($r['ok']) return $r;
+ $errs[] = ($u['token'] ? '直连' : $u['url']) . ':' . $r['error'];
+ $out['http'] = $r['http'];
+ }
+ $out['error'] = '接口请求失败(' . implode(' / ', $errs) . ')';
+ return $out;
+}
+
+/**
+ * tag 规范化成项目版本号:V1.84 => 1840
+ * 与 $WEBQB 的规则一致:主版本占千位、次版本占百十位、补丁占个位(补丁最多记 9)
+ */
+function mnbt_updater_tag_to_version($tag)
+{
+ $tag = (string)$tag;
+ if (!preg_match('/(\d+)(?:[.\-](\d+))?(?:[.\-](\d+))?/', $tag, $m)) return 0;
+ $major = (int)$m[1];
+ $minor = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : 0;
+ $patch = isset($m[3]) && $m[3] !== '' ? (int)$m[3] : 0;
+ return $major * 1000 + $minor * 10 + min(9, $patch);
+}
+
+/** tag 显示名(GitHub 上的 tag 已带 V 前缀,这里只做兜底) */
+function mnbt_updater_tag_display($tag)
+{
+ $tag = trim((string)$tag);
+ if ($tag === '') return '';
+ return (preg_match('/^v/i', $tag) ? '' : 'V') . $tag;
+}
+
+/** 从 release 的 assets 里挑自定义 zip 附件 */
+function mnbt_updater_pick_asset($assets)
+{
+ if (!is_array($assets)) return ['url' => '', 'name' => '', 'size' => 0];
+ foreach ($assets as $a) {
+ if (!is_array($a)) continue;
+ $url = isset($a['browser_download_url']) ? (string)$a['browser_download_url'] : '';
+ $name = isset($a['name']) ? (string)$a['name'] : '';
+ if ($url === '') continue;
+ if (!preg_match('#\.zip$#i', $name !== '' ? $name : $url)) continue;
+ return ['url' => $url, 'name' => $name, 'size' => (int)($a['size'] ?? 0)];
+ }
+ return ['url' => '', 'name' => '', 'size' => 0];
+}
+
+/**
+ * 下载候选地址:包来源优先级为 release 自定义 zip 附件 > GitHub 自动源码包;
+ * 每个来源再按 github.com 直连、配置镜像顺序依次尝试
+ */
+function mnbt_updater_candidates($cfg, $tag, $asset_url)
+{
+ $srcs = [];
+ if ($asset_url !== '') {
+ $srcs[] = ['type' => 'asset', 'label' => 'Release 附件', 'url' => $asset_url];
+ }
+ // archive/refs/tags 会由 GitHub 跳到 codeload,legacy.zip 是 codeload 的直链形式,两种都留作兜底
+ $srcs[] = [
+ 'type' => 'source',
+ 'label' => 'GitHub 源码包',
+ 'url' => 'https://github.com/' . $cfg['repo'] . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
+ ];
+ $srcs[] = [
+ 'type' => 'source',
+ 'label' => 'GitHub 源码包(直连 codeload)',
+ 'url' => 'https://codeload.github.com/' . $cfg['repo'] . '/legacy.zip/refs/tags/' . rawurlencode($tag),
+ ];
+
+ $out = [];
+ foreach ($srcs as $s) {
+ $out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $s['url'], 'via' => 'github', 'mirror' => ''];
+ foreach ($cfg['mirrors'] as $m) {
+ $out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $m . $s['url'], 'via' => 'mirror', 'mirror' => $m];
+ }
+ }
+ return $out;
+}
+
+/** 检查结果缓存文件(后台首页每次加载都要问一遍,不限流会很快打满匿名请求配额) */
+function mnbt_updater_cache_file()
+{
+ $dir = mnbt_updater_root() . 'runtime/cache';
+ if (!is_dir($dir)) mnbt_updater_mkdir($dir);
+ return $dir . '/github_updater_check.json';
+}
+
+/** 检查最新版本;返回展示与下载所需的全部信息 */
+function mnbt_updater_check($force = false, $ttl = 1800)
+{
+ $cfg = mnbt_updater_config();
+ $cur = (int)($GLOBALS['WEBQB'] ?? 0);
+ $out = [
+ 'ok' => 0, 'error' => '', 'repo' => $cfg['repo'], 'mirrors' => $cfg['mirrors'],
+ 'has_token' => ($cfg['github_token'] !== ''), 'current' => 'V' . sprintf('%.2f', $cur / 1000),
+ 'current_version' => $cur, 'tag' => '', 'latest' => '', 'version' => 0, 'name' => '', 'body' => '',
+ 'asset_url' => '', 'asset_name' => '', 'asset_size' => 0, 'source' => '', 'source_label' => '',
+ 'published_at' => '', 'fallback' => 0, 'can_update' => 0, 'candidates' => [],
+ ];
+ if ($cfg['repo'] === '') {
+ $out['error'] = '未配置更新仓库';
+ return $out;
+ }
+
+ // 仓库或本地版本变了要重新问;失败结果最多缓存 5 分钟
+ $cf = mnbt_updater_cache_file();
+ if (!$force && is_file($cf)) {
+ $c = json_decode((string)@file_get_contents($cf), true);
+ if (is_array($c) && isset($c['ts'], $c['repo'], $c['ver'], $c['data'])
+ && $c['repo'] === $cfg['repo'] && (int)$c['ver'] === $cur) {
+ $life = !empty($c['data']['ok']) ? min((int)($c['ttl'] ?? 1800), (int)$ttl) : min(300, (int)$ttl);
+ if (time() - (int)$c['ts'] < $life) {
+ return $c['data'];
+ }
+ }
+ }
+
+ $rel = null;
+ $err1 = '';
+ $api = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases/latest', $cfg);
+ if ($api['ok']) {
+ $js = $api['json'];
+ // latest 接口对非数组返回也会给 200,这里靠 tag_name 判定
+ if (isset($js['tag_name']) && mnbt_updater_tag_to_version($js['tag_name']) > 0) {
+ $rel = $js;
+ }
+ } else {
+ $err1 = $api['error'];
+ }
+ if (!is_array($rel)) {
+ // latest 不可用(未来 tag 可能不再以 V 开头,或接口限流),退化成列表取版本号最高的一个
+ $list = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases?per_page=20', $cfg);
+ if (!$list['ok']) {
+ $out['error'] = $list['error'] !== '' ? $list['error'] : $err1;
+ mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+ return $out;
+ }
+ $best = null;
+ $bestv = 0;
+ foreach ($list['json'] as $js) {
+ if (!is_array($js) || empty($js['tag_name'])) continue;
+ if (!empty($js['draft']) || !empty($js['prerelease'])) continue;
+ $v = mnbt_updater_tag_to_version($js['tag_name']);
+ if ($v > $bestv) { $bestv = $v; $best = $js; }
+ }
+ if (!is_array($best)) {
+ $out['error'] = $err1 !== '' ? $err1 : 'GitHub 未找到可用的版本发布';
+ mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+ return $out;
+ }
+ $rel = $best;
+ $out['fallback'] = 1;
+ }
+
+ $tag = (string)$rel['tag_name'];
+ $asset = mnbt_updater_pick_asset($rel['assets'] ?? []);
+ $ver = mnbt_updater_tag_to_version($tag);
+
+ $out['ok'] = 1;
+ $out['tag'] = $tag;
+ $out['latest'] = mnbt_updater_tag_display($tag);
+ $out['version'] = $ver;
+ $out['name'] = (string)($rel['name'] ?? '');
+ $out['body'] = (string)($rel['body'] ?? '');
+ $out['published_at'] = (string)($rel['published_at'] ?? '');
+ $out['asset_url'] = $asset['url'];
+ $out['asset_name'] = $asset['name'];
+ $out['asset_size'] = $asset['size'];
+ $out['can_update'] = ($ver > $cur) ? 1 : 0;
+ $out['source'] = $asset['url'] !== '' ? 'asset' : 'source';
+ $out['source_label'] = $asset['url'] !== '' ? 'Release 附件 ' . $asset['name'] : 'GitHub 自动源码包(需剥顶层目录)';
+ $out['candidates'] = mnbt_updater_candidates($cfg, $tag, $asset['url']);
+ mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
+ return $out;
+}
+
+/** 写检查结果缓存;缓存里不含 Token,只存展示与下载所需数据 */
+function mnbt_updater_cache_put($cfg, $cur, $out, $ttl = 1800)
+{
+ $cf = mnbt_updater_cache_file();
+ @file_put_contents($cf, json_encode([
+ 'ts' => time(),
+ 'ttl' => (int)$ttl,
+ 'repo' => $cfg['repo'],
+ 'ver' => (int)$cur,
+ 'data' => $out,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+}
+
+/**
+ * 流式下载压缩包到临时文件:逐跳校验地址、校验 zip 魔数与字节数,失败即删除
+ * @return bool
+ */
+function mnbt_updater_download($url, $dest, $cfg, &$err)
+{
+ $err = '';
+ if (!function_exists('curl_init')) {
+ $err = '服务器未启用 curl 扩展';
+ return false;
+ }
+ if (!mnbt_updater_url_safe($url, $cfg, $err)) return false;
+ $fp = @fopen($dest, 'wb');
+ if (!$fp) {
+ $err = '无法创建临时文件 ' . $dest;
+ return false;
+ }
+ $cur = (string)$url;
+ $ok = false;
+ $heads = [];
+ for ($hop = 0; $hop < 6; $hop++) {
+ if (!mnbt_updater_url_safe($cur, $cfg, $err)) break;
+ $heads = [];
+ $ch = curl_init($cur);
+ curl_setopt($ch, CURLOPT_FILE, $fp);
+ curl_setopt($ch, CURLOPT_HEADER, false);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 300);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ // 下载请求不带 Token,避免跳转或镜像把它带出去
+ 'User-Agent: MNBT-Updater/1.85',
+ 'Accept: application/octet-stream',
+ ]);
+ curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$heads) {
+ $len = strlen($line);
+ $p = strpos($line, ':');
+ if ($p !== false) {
+ $heads[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1));
+ }
+ return $len;
+ });
+ $res = curl_exec($ch);
+ $cerr = curl_error($ch);
+ $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($res === false) {
+ $err = $cerr !== '' ? $cerr : '下载中断';
+ break;
+ }
+ if ($code >= 300 && $code < 400) {
+ $loc = $heads['location'] ?? '';
+ if ($loc === '') {
+ $err = 'HTTP ' . $code . ' 但未返回跳转地址';
+ break;
+ }
+ $next = mnbt_updater_abs_url($cur, $loc);
+ if ($next === '') {
+ $err = '跳转地址无效';
+ break;
+ }
+ $cur = $next;
+ // 跳转响应可能自带一小段 HTML,会把压缩包开头弄脏,跳一次就把文件截回空
+ rewind($fp);
+ ftruncate($fp, 0);
+ continue;
+ }
+ if ($code !== 200) {
+ $err = 'HTTP ' . $code;
+ break;
+ }
+ $ok = true;
+ break;
+ }
+ fflush($fp);
+ $bytes = ftell($fp);
+ fclose($fp);
+
+ if ($ok) {
+ $expect = (int)($heads['content-length'] ?? 0);
+ if ($expect > 0 && $expect !== $bytes) {
+ $err = '文件不完整,应为 ' . $expect . ' 字节,实际 ' . $bytes . ' 字节';
+ $ok = false;
+ } elseif ($bytes < 1024) {
+ $err = '下载内容过小(' . $bytes . ' 字节),不像有效的压缩包';
+ $ok = false;
+ } elseif (!mnbt_updater_is_zip($dest)) {
+ $err = '下载内容不是有效的 zip 压缩包(可能是镜像返回的错误页)';
+ $ok = false;
+ }
+ }
+ if (!$ok) {
+ @unlink($dest);
+ }
+ return $ok;
+}
+
+/** 相对跳转地址转绝对 */
+function mnbt_updater_abs_url($base, $loc)
+{
+ $loc = trim((string)$loc);
+ if ($loc === '') return '';
+ if (preg_match('#^https?://#i', $loc)) return $loc;
+ $p = parse_url($base);
+ if (!$p || empty($p['host'])) return '';
+ $scheme = isset($p['scheme']) ? $p['scheme'] : 'https';
+ if (strpos($loc, '/') === 0) return $scheme . '://' . $p['host'] . $loc;
+ $dir = isset($p['path']) ? substr($p['path'], 0, strrpos($p['path'], '/') + 1) : '/';
+ return $scheme . '://' . $p['host'] . $dir . $loc;
+}
+
+/** zip 魔数校验 */
+function mnbt_updater_is_zip($file)
+{
+ if (!is_file($file)) return false;
+ $fp = @fopen($file, 'rb');
+ if (!$fp) return false;
+ $magic = fread($fp, 4);
+ fclose($fp);
+ return $magic === "PK\x03\x04" || $magic === "PK\x05\x06";
+}
+
+/** zip 条目名安全校验:禁止绝对路径、盘符、反斜杠与 .. 段 */
+function mnbt_updater_entry_safe($name)
+{
+ $name = (string)$name;
+ if ($name === '' || strpos($name, "\0") !== false) return false;
+ if (strpos($name, '/') === 0) return false;
+ if (strpos($name, '\\') !== false) return false;
+ if (preg_match('#^[A-Za-z]:#', $name)) return false;
+ foreach (explode('/', $name) as $seg) {
+ if ($seg === '..') return false;
+ }
+ return true;
+}
+
+/**
+ * 解压前扫描:拒绝危险条目、识别需要剥掉的顶层目录、确认剥完之后确实是站点根
+ * @return array|null 成功返回 ['strip'=>..,'entries'=>..,'files'=>..],失败返回 null 并写 $err
+ */
+function mnbt_updater_zip_analyze($file, &$err)
+{
+ $err = '';
+ if (!class_exists('ZipArchive')) {
+ $err = '服务器未启用 Zip 扩展,无法在线更新';
+ return null;
+ }
+ $zip = new ZipArchive();
+ $op = $zip->open($file);
+ if ($op !== true) {
+ $err = '压缩包无法打开(错误码 ' . $op . '),可能下载不完整';
+ return null;
+ }
+ $n = (int)$zip->numFiles;
+ if ($n <= 0) {
+ $zip->close();
+ $err = '压缩包是空的';
+ return null;
+ }
+ $names = [];
+ $files = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $e = $zip->statIndex($i);
+ $name = isset($e['name']) ? (string)$e['name'] : '';
+ if (!mnbt_updater_entry_safe($name)) {
+ $zip->close();
+ $err = '压缩包含非法路径,已中止:' . $name;
+ return null;
+ }
+ $names[] = $name;
+ if (substr($name, -1) !== '/') $files++;
+ }
+ $strip = mnbt_updater_zip_strip($names);
+ // 附件包(无顶层目录)与源码包(剥掉顶层目录后)都必须落在站点根上,否则视为包内容不对
+ if (!mnbt_updater_zip_looks_like_site($names, $strip)) {
+ $zip->close();
+ $err = '压缩包结构不符合站点根目录,已中止';
+ return null;
+ }
+ $zip->close();
+ return ['strip' => $strip, 'entries' => $n, 'files' => $files];
+}
+
+/**
+ * 顶层目录识别:GitHub 自动源码包的顶层名有两种(MNBT-1.84/ 与 1181469655-MNBT-<sha>/),
+ * 所以只按「所有条目共享同一个顶层目录」来判断,不硬编码名字
+ */
+function mnbt_updater_zip_strip($names)
+{
+ $top = null;
+ foreach ($names as $name) {
+ $pos = strpos($name, '/');
+ if ($pos === false || $pos === 0) return '';
+ $seg = substr($name, 0, $pos);
+ if ($top === null) {
+ $top = $seg;
+ } elseif ($top !== $seg) {
+ return '';
+ }
+ }
+ return $top === null ? '' : $top . '/';
+}
+
+/** 剥掉顶层目录后是否还是一个站点根 */
+function mnbt_updater_zip_looks_like_site($names, $strip)
+{
+ $need = ['MPHX/function.php', 'admin/ajax.php', 'index.php', 'cf_up.php'];
+ $len = strlen($strip);
+ foreach ($names as $name) {
+ if ($strip !== '' && strncmp($name, $strip, $len) !== 0) continue;
+ $rel = rtrim($strip === '' ? $name : substr($name, $len), '/');
+ if ($rel !== '' && in_array($rel, $need, true)) return true;
+ }
+ return false;
+}
+
+/** 逐级建目录 */
+function mnbt_updater_mkdir($dir)
+{
+ if (is_dir($dir)) return true;
+ return @mkdir($dir, 0755, true);
+}
+
+/**
+ * 逐条目解压(需要剥顶层目录时 extractTo 做不到,所以统一走这里)
+ * @return bool
+ */
+function mnbt_updater_extract($file, $root, $strip, &$err)
+{
+ $err = '';
+ $zip = new ZipArchive();
+ if ($zip->open($file) !== true) {
+ $err = '压缩包打开失败';
+ return false;
+ }
+ $n = (int)$zip->numFiles;
+ for ($i = 0; $i < $n; $i++) {
+ $e = $zip->statIndex($i);
+ $name = isset($e['name']) ? (string)$e['name'] : '';
+ if (!mnbt_updater_entry_safe($name)) {
+ $zip->close();
+ $err = '压缩包含非法路径,已中止:' . $name;
+ return false;
+ }
+ $rel = $name;
+ if ($strip !== '') {
+ if (strpos($name, $strip) !== 0) continue;
+ $rel = substr($name, strlen($strip));
+ }
+ if ($rel === '') continue;
+ $target = $root . $rel;
+ if (substr($name, -1) === '/') {
+ if (!is_dir($target) && !mnbt_updater_mkdir($target)) {
+ $zip->close();
+ $err = '目录创建失败:' . $rel;
+ return false;
+ }
+ continue;
+ }
+ $dir = dirname($target);
+ if (!is_dir($dir) && !mnbt_updater_mkdir($dir)) {
+ $zip->close();
+ $err = '目录创建失败:' . $dir;
+ return false;
+ }
+ $src = $zip->getStream($name);
+ if ($src === false) {
+ $zip->close();
+ $err = '条目读取失败:' . $rel;
+ return false;
+ }
+ $dst = @fopen($target, 'wb');
+ if (!$dst) {
+ fclose($src);
+ $zip->close();
+ $err = '文件写入失败:' . $rel;
+ return false;
+ }
+ stream_copy_to_stream($src, $dst);
+ fclose($dst);
+ fclose($src);
+ }
+ $zip->close();
+ return true;
+}
+
+/** 需要备份还原的本地文件:装完就有、包里有但不能被覆盖 */
+function mnbt_updater_local_files()
+{
+ return ['config.php', 'cf_up.php', 'MPHX/SQ.php', 'install/install.lock'];
+}
+
+/** 需要备份还原的本地目录:包里可能带着作者的数据,整目录换回来 */
+function mnbt_updater_local_dirs()
+{
+ return ['api/cookie'];
+}
+
+/** 递归删除目录(只用于更新流程自己产生的临时目录) */
+function mnbt_updater_rrmdir($dir)
+{
+ if (!is_dir($dir)) return;
+ $items = @scandir($dir);
+ if (!is_array($items)) return;
+ foreach ($items as $it) {
+ if ($it === '.' || $it === '..') continue;
+ $p = $dir . '/' . $it;
+ if (is_dir($p)) {
+ mnbt_updater_rrmdir($p);
+ } else {
+ @unlink($p);
+ }
+ }
+ @rmdir($dir);
+}
+
+/** 递归复制目录 */
+function mnbt_updater_copy_dir($src, $dst)
+{
+ if (!is_dir($src)) return;
+ if (!is_dir($dst) && !mnbt_updater_mkdir($dst)) return;
+ $items = @scandir($src);
+ if (!is_array($items)) return;
+ foreach ($items as $it) {
+ if ($it === '.' || $it === '..') continue;
+ $s = $src . '/' . $it;
+ $d = $dst . '/' . $it;
+ if (is_dir($s)) {
+ mnbt_updater_copy_dir($s, $d);
+ } else {
+ @copy($s, $d);
+ }
+ }
+}
+
+/**
+ * 备份本地文件(记录哪些原本存在,install.lock 之类要按原样决定留还是删)
+ * @return array ['dir'=>备份目录,'files'=>[相对路径=>是否已备份],'dirs'=>[...],'present'=>[...]]
+ */
+function mnbt_updater_backup_local($root, $bak_dir, &$err)
+{
+ $err = '';
+ $st = ['dir' => $bak_dir, 'files' => [], 'dirs' => [], 'present' => []];
+ if (!mnbt_updater_mkdir($bak_dir)) {
+ $err = '备份目录创建失败:' . $bak_dir;
+ return $st;
+ }
+ foreach (mnbt_updater_local_files() as $rel) {
+ $src = $root . $rel;
+ $st['present'][$rel] = is_file($src);
+ if (!is_file($src)) continue;
+ $dst = $bak_dir . '/' . $rel;
+ if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
+ $err = '备份目录创建失败:' . dirname($dst);
+ continue;
+ }
+ if (@copy($src, $dst)) {
+ $st['files'][$rel] = 1;
+ } else {
+ $err = '本地文件备份失败:' . $rel;
+ }
+ }
+ foreach (mnbt_updater_local_dirs() as $rel) {
+ $src = rtrim($root, '/') . '/' . $rel;
+ $st['present'][$rel] = is_dir($src);
+ if (!is_dir($src)) continue;
+ mnbt_updater_copy_dir($src, $bak_dir . '/' . $rel);
+ $st['dirs'][$rel] = 1;
+ }
+ return $st;
+}
+
+/**
+ * 还原本地文件;backup 为空数组时按「跳过备份」处理,只保证不会误判成未安装
+ * @return bool
+ */
+function mnbt_updater_restore_local($root, $backup, &$err)
+{
+ $err = '';
+ $dir = isset($backup['dir']) ? $backup['dir'] : '';
+ if ($dir === '' || !is_dir($dir)) return true;
+ foreach ($backup['files'] as $rel => $ok) {
+ if (empty($ok)) continue;
+ $bak = $dir . '/' . $rel;
+ if (is_file($bak)) {
+ $dst = $root . $rel;
+ if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
+ $err .= '无法还原目录:' . dirname($dst) . ';';
+ continue;
+ }
+ if (!@copy($bak, $dst)) $err .= '本地文件还原失败:' . $rel . ';';
+ }
+ }
+ // 新包带的安装锁不能让站点被判定为未安装,原本没有就必须删掉
+ $lock = $root . 'install/install.lock';
+ if (empty($backup['files']['install/install.lock']) && is_file($lock)) {
+ @unlink($lock);
+ }
+ foreach ($backup['dirs'] as $rel => $ok) {
+ if (empty($ok)) continue;
+ $target = rtrim($root, '/') . '/' . $rel;
+ mnbt_updater_rrmdir($target);
+ mnbt_updater_copy_dir($dir . '/' . $rel, $target);
+ }
+ // 站点原本没有这些目录时,包里带进来的同名内容一律清掉(例如作者打包残留的 cookie)
+ foreach ($backup['present'] as $rel => $existed) {
+ if (!empty($existed)) continue;
+ $p = rtrim($root, '/') . '/' . $rel;
+ if (is_dir($p)) {
+ mnbt_updater_rrmdir($p);
+ }
+ }
+ return $err === '';
+}
+
+/** 更新流程的兜底状态:任何中断路径(超时、致命错误、用户关页面)都要收拾残局 */
+function mnbt_updater_guard_start()
+{
+ $GLOBALS['mnbt_updater_guard'] = [
+ 'running' => 1, 'root' => mnbt_updater_root(), 'zip' => '', 'bak' => '',
+ 'admin_now' => '', 'admin_orig' => '', 'moved' => 0, 'backup' => null, 'restored' => 0,
+ ];
+ register_shutdown_function('mnbt_updater_guard_shutdown');
+}
+
+/** 更新兜底状态的某个键 */
+function mnbt_updater_guard($key, $val)
+{
+ if (!isset($GLOBALS['mnbt_updater_guard']) || !is_array($GLOBALS['mnbt_updater_guard'])) {
+ $GLOBALS['mnbt_updater_guard'] = [];
+ }
+ $GLOBALS['mnbt_updater_guard'][$key] = $val;
+}
+
+/** 流程正常收尾:关掉兜底,避免残局处理再动一次文件 */
+function mnbt_updater_guard_done()
+{
+ $GLOBALS['mnbt_updater_guard']['running'] = 0;
+}
+
+/**
+ * 回滚/收尾:还原本地配置 → 把改名后的后台目录还原 → 清临时包与备份
+ * 成功与失败都走这里,所以逻辑必须可重复执行
+ */
+function mnbt_updater_guard_rollback()
+{
+ $g = $GLOBALS['mnbt_updater_guard'] ?? null;
+ if (!is_array($g)) return;
+ $root = !empty($g['root']) ? $g['root'] : mnbt_updater_root();
+ if (!empty($g['backup']) && empty($g['restored'])) {
+ $re = '';
+ mnbt_updater_restore_local($root, $g['backup'], $re);
+ $GLOBALS['mnbt_updater_guard']['restored'] = 1;
+ }
+ if (!empty($g['moved']) && !empty($g['admin_orig']) && !empty($g['admin_now']) && $g['admin_now'] !== $g['admin_orig']) {
+ if (!file_exists($g['admin_orig'])) {
+ @rename($g['admin_now'], $g['admin_orig']);
+ } elseif (is_dir($g['admin_orig']) && !is_dir($g['admin_now'] . '/api')) {
+ // 新包里恰好带了同名目录:先删掉空的占位目录再改回来
+ @rmdir($g['admin_orig']);
+ if (!file_exists($g['admin_orig'])) @rename($g['admin_now'], $g['admin_orig']);
+ }
+ }
+ if (!empty($g['zip']) && is_file($g['zip'])) @unlink($g['zip']);
+ if (!empty($g['bak']) && is_dir($g['bak'])) mnbt_updater_rrmdir($g['bak']);
+}
+
+/** 关机兜底:流程没正常跑完时至少把站点恢复成可访问状态 */
+function mnbt_updater_guard_shutdown()
+{
+ $g = $GLOBALS['mnbt_updater_guard'] ?? null;
+ if (!is_array($g) || empty($g['running'])) return;
+ mnbt_updater_guard_rollback();
+}
+
+/**
+ * 执行升级 SQL(只执行 update/update.sql,逻辑与旧版一致:按分号逐句跑)
+ * 注意参数顺序是 mysqli(host, user, pwd, dbname[, port]),旧代码把 dbname 和 pwd 传反了,
+ * 导致连接总是失败,这里按正确顺序传
+ * @return string 空串表示成功,否则为错误说明
+ */
+function mnbt_updater_run_sql($file, $dbconfig)
+{
+ if (!is_file($file)) return '';
+ $_sql = @file_get_contents($file);
+ if ($_sql === false || trim($_sql) === '') return '';
+ $port = !empty($dbconfig['port']) ? (int)$dbconfig['port'] : 3306;
+ try {
+ $_mysqli = new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['pwd'], $dbconfig['dbname'], $port);
+ } catch (Throwable $e) {
+ return '连接数据库出错:' . $e->getMessage();
+ }
+ if (mysqli_connect_errno()) {
+ return '连接数据库出错:' . mysqli_connect_error();
+ }
+ $_mysqli->query('set names utf8;');
+ foreach (explode(';', $_sql) as $_value) {
+ if (trim((string)$_value) === '') continue;
+ $_mysqli->query($_value);
+ }
+ $_mysqli->close();
+ return '';
+}
diff --git a/admin/ajax.php b/admin/ajax.php
index dfc590b..c475853 100644
--- a/admin/ajax.php
+++ b/admin/ajax.php
@@ -21,5 +21,6 @@ require_once './api/dd.php';
require_once './api/gg.php';
require_once './api/plugin.php';
require_once './api/docker.php';
+require_once './api/update_setting.php';
json_exit('系统指令不存在!');
?>
diff --git a/admin/api/bt.php b/admin/api/bt.php
index 939d384..f4e33d5 100644
--- a/admin/api/bt.php
+++ b/admin/api/bt.php
@@ -69,27 +69,29 @@ if($egn=='xgjl') {
}
if($egn=='mnbt') {
include("../MPHX/BL.php");
- include("../MPHX/SQ.php");
- include("../cf_up.php");
- $gxtj = array(
- 'url' => $_SERVER['HTTP_HOST'],
- 'authcode' => $authcode,
- 'ver' => $WEBQB,
- );
- $result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
- $content=json_decode($result, true);
+ include_once("../MPHX/github_updater.php");
+ // 只走 GitHub Release 检查更新,不再回连自建服务器,也不再校验 authcode
+ $content = mnbt_updater_check();
$total ='V'.sprintf( "%.2f ",$WEBQB/1000);
- if($content['code']=='0') {
- $cl='mdi-bookmark-check';
- $gx='您使用的已是最新版本!';
- } elseif($content['code']=='1') {
+ if($content['ok']!=1) {
+ $cl='mdi-cloud-off';
+ $gx='暂时无法检查更新:'.$content['error'];
+ } elseif($content['can_update']==1) {
$cl='mdi-arrow-up-bold-circle';
$gx='已经有新版本推出!请前往系统管理->系统更新处进行更新';
- } elseif($content['code']=='-1') {
- $cl='mdi-account-off';
- $gx='离线模式不提供更新!!!';
+ } else {
+ $cl='mdi-bookmark-check';
+ $gx='您使用的已是最新版本!';
}
- exit(json_encode(['cl'=>$cl,'gx'=>$gx,'vs'=>$total,'gg'=>$content['gg']],256));
+ exit(json_encode([
+ 'cl'=>$cl,
+ 'gx'=>$gx,
+ 'vs'=>$total,
+ 'gg'=>'',
+ 'newver'=>$content['latest'],
+ 'cver'=>$content['current'],
+ 'canupdate'=>$content['can_update'],
+ ],256));
}
if($egn=='listbt') {
//宝塔列表
diff --git a/admin/api/gg.php b/admin/api/gg.php
index a92bef4..2f97c7d 100644
--- a/admin/api/gg.php
+++ b/admin/api/gg.php
@@ -6,66 +6,133 @@ if($egn=='gglist') {
exit($result);
}
if($egn=='update') {
+ // 更新包体积大、解压慢,先放宽执行时间;用户中途关页面也要把流程走完
+ @set_time_limit(600);
+ @ignore_user_abort(true);
include("../MPHX/BL.php");
- include("../MPHX/SQ.php");
+ include_once("../MPHX/github_updater.php");
include("../cf_up.php");
- $gxtj = array(
- 'url' => $_SERVER['HTTP_HOST'],
- 'authcode' => $authcode,
- 'ver' => $WEBQB,
- );
- $result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
- $query=json_decode($result, true);
- if($query['code']=="1") {
- //file_put_contents("gxwj.zip",$blh);
- copy($query['file'],'gxwj.zip');
- $url = $_SERVER['PHP_SELF'];
- $filenamet= str_ireplace('ajax.php', '', $url);
- $filename= str_ireplace('/', '', $filenamet);
- //exit('{"code":"'.$filename.'"}');
- if(rename('../'.$filename.'/','../admin/')) {
- $file = "gxwj.zip";
- $outPath = "../";
- $zip = new ZipArchive();
- $openRes = $zip->open($file);
- if ($openRes === TRUE) {
- $zip->extractTo($outPath);
- $zip->close();
- unlink($file);
- rename('../admin/','../'.$filename.'/');
- $path='../update/update.sql';
- if(file_exists($path)) {
- function insert($file,$database,$name,$root,$pwd) {
- header("Content-type: text/html; charset=utf-8");
- $_sql = file_get_contents($file);
- $_arr = explode(';', $_sql);
- $_mysqli = new mysqli($name,$root,$pwd,$database);
- //第一个参数为域名,第二个为用户名,第三个为密码,第四个为数据库名字
- if (mysqli_connect_errno()) {
- exit('连接数据库出错');
- } else {
- $_mysqli->query('set names utf8;');
- foreach ($_arr as $_value) {
- $_mysqli->query($_value.';');
- }
- }
- $_mysqli->close();
- $_mysqli = null;
- }
- insert($path,$dbconfig['user'],$dbconfig['host'],$dbconfig['dbname'],$dbconfig['pwd']);
- unlink($path);
- @rmdir('../update/');
- }
- json_exit('更新成功~请手动刷新页面');
- } else {
- json_exit('解压失败');
- }
- } else {
- json_exit('失败');
+
+ mnbt_updater_guard_start();
+ $up_cfg = mnbt_updater_config();
+ // 用真实路径做文件操作,ROOT 万一是软链或大小写不同也不会把文件写到别处
+ $root_real = str_replace('\\', '/', realpath(rtrim(mnbt_updater_root(),'/')) ?: rtrim(mnbt_updater_root(),'/'));
+ $root_sl = $root_real.'/'; // 站点根,带尾斜杠
+ $root = $root_real; // 站点根,不带尾斜杠
+ mnbt_updater_guard('root',$root_sl);
+
+ // 1. 检查最新版本(只走 GitHub Release,不再校验 authcode);执行更新前必须拿最新的下载地址
+ $chk = mnbt_updater_check(true);
+ if(!$chk['ok']) {
+ mnbt_updater_guard_done();
+ json_exit_error('检查更新失败:'.$chk['error']);
+ }
+ if(!$chk['can_update']) {
+ mnbt_updater_guard_done();
+ json_exit_error('当前已是最新版本('.$chk['current'].'),无需更新');
+ }
+
+ // 2. 临时目录放站点根的 runtime/temp 下(已在 .gitignore 内,且后台目录改名后依然可访问)
+ $tmp_dir = $root.'/runtime/temp/update_tmp';
+ if(!is_dir($tmp_dir) && !mnbt_updater_mkdir($tmp_dir)) {
+ mnbt_updater_guard_done();
+ json_exit_error('临时目录创建失败,请检查 runtime 目录权限');
+ }
+ $zip_file = $tmp_dir.'/gxwj-'.mt_rand(100000,999999).'.zip';
+ mnbt_updater_guard('zip',$zip_file);
+
+ // 3. 下载:Release 附件优先,回落自动源码包;每个来源按 github 直连、配置镜像顺序依次尝试
+ $used = null;
+ $tried = [];
+ foreach($chk['candidates'] as $c) {
+ $e = '';
+ if(mnbt_updater_download($c['url'],$zip_file,$up_cfg,$e)) {
+ $used = $c;
+ break;
+ }
+ $tried[] = ($c['via']==='mirror' ? $c['mirror'] : 'github 直连').':'.$e;
+ }
+ if($used === null) {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('更新包下载失败(已尝试 '.count($tried).' 个来源):'.implode(' / ',$tried));
+ }
+
+ // 4. 解压前校验:拒绝危险路径,识别并准备剥掉顶层目录
+ $za = mnbt_updater_zip_analyze($zip_file,$zerr);
+ if(!$za) {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('更新包校验失败:'.$zerr);
+ }
+
+ // 5. 备份本地文件:config.php、cf_up.php、MPHX/SQ.php、install/install.lock、api/cookie/
+ $bak_dir = $tmp_dir.'/bak_'.mt_rand(100000,999999);
+ $backup = mnbt_updater_backup_local($root_sl,$bak_dir,$bk_err);
+ mnbt_updater_guard('bak',$bak_dir);
+ mnbt_updater_guard('backup',$backup);
+ if($bk_err!=='') {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('本地配置备份失败:'.$bk_err);
+ }
+
+ // 6. 当前后台目录改名成标准的 admin,新包里的 admin 才能落在正确位置
+ $admin_now = str_replace('\\','/',dirname(__DIR__));
+ $admin_std = $root.'/admin';
+ mnbt_updater_guard('admin_now',$admin_std);
+ mnbt_updater_guard('admin_orig',$admin_now);
+ if(basename($admin_now)!=='admin' || strcasecmp(dirname($admin_now),$root)!==0) {
+ if(is_dir($admin_std)) {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('站点根目录已存在一个 admin 目录,与当前后台目录名冲突,已中止更新');
+ }
+ if(!@rename($admin_now,$admin_std)) {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('后台目录改名失败,已中止更新');
}
- } else {
- json_exit('无法更新');
+ mnbt_updater_guard('moved',1);
+ }
+
+ // 7. 解压覆盖到站点根(附件包 zip 根即站点根;源码包剥掉顶层目录后同样如此)
+ if(!mnbt_updater_extract($zip_file,$root_sl,$za['strip'],$ex_err)) {
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+ json_exit_error('解压覆盖失败:'.$ex_err);
+ }
+
+ // 8. 升级 SQL:只执行包里带的 update/update.sql,位置在剥顶层目录后不变
+ $sql_msg = '';
+ $sql_file = $root.'/update/update.sql';
+ if(is_file($sql_file)) {
+ $sql_msg = mnbt_updater_run_sql($sql_file,$dbconfig);
+ if($sql_msg==='') {
+ // 执行成功才删,出错时保留文件让管理员可以手工再跑一次
+ @unlink($sql_file);
+ @rmdir($root.'/update/');
+ }
+ }
+
+ // 9. 还原本地配置与安装锁、把后台目录改回原名、清临时文件
+ mnbt_updater_guard_rollback();
+ mnbt_updater_guard_done();
+
+ $via_label = $used['via']==='mirror' ? $used['mirror'] : 'github 直连';
+ if(function_exists('logjl')) {
+ logjl($user ?? '', '系统更新', '更新到 '.$chk['latest'].'('.$used['label'].',来源 '.$via_label.')', $sql_msg===''?'更新成功':'更新成功但升级SQL有误', $DB);
+ }
+ if($sql_msg!=='') {
+ json_exit_error('文件已更新,但升级 SQL 执行失败:'.$sql_msg.'(update/update.sql 已保留,可手工执行)', [
+ 'ver' => $chk['latest'], 'tag' => $chk['tag'],
+ 'source' => $used['label'], 'via' => $via_label, 'strip' => $za['strip'], 'files' => $za['files'],
+ ]);
}
+ json_exit('更新成功~请手动刷新页面', [
+ 'qk' => 1, 'ver' => $chk['latest'], 'tag' => $chk['tag'],
+ 'source' => $used['label'], 'via' => $via_label, 'strip' => $za['strip'], 'files' => $za['files'],
+ ]);
return;
}
return;
diff --git a/admin/api/update_setting.php b/admin/api/update_setting.php
new file mode 100644
index 0000000..cb17a94
--- /dev/null
+++ b/admin/api/update_setting.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * 系统更新设置(GitHub Release)
+ * setting.php 归外部 API 兼容改造占用,更新相关的配置单独走这个文件
+ */
+if($egn=='upset') {
+ include_once("../MPHX/BL.php");
+ include_once("../MPHX/github_updater.php");
+ include("../cf_up.php");
+
+ $repo = trim((string)($_POST['repo'] ?? ''));
+ if($repo==='') json_exit_error('请填写更新仓库,格式为 owner/repo');
+
+ $mirrors_raw = $_POST['mirrors'] ?? '';
+ if(is_array($mirrors_raw)) {
+ $mirrors = $mirrors_raw;
+ } else {
+ $mirrors = preg_split('/\r\n|\r|\n|,/', (string)$mirrors_raw);
+ }
+ $mirrors = array_values(array_filter(array_map('trim', (array)$mirrors), function($v){ return $v!==''; }));
+
+ // Token 留空表示保持原值,勾选清除才写空;任何场合都不回显明文
+ $token = null;
+ if(!empty($_POST['clear_token'])) {
+ $token = '';
+ } elseif(trim((string)($_POST['github_token'] ?? ''))!=='') {
+ $token = trim((string)$_POST['github_token']);
+ }
+
+ $res = mnbt_updater_save_config($repo, $mirrors, $token);
+ if(!$res['ok']) json_exit_error($res['error']);
+
+ $cfg = mnbt_updater_config();
+ logjl($user ?? '', '系统更新', '更新仓库设为 '.$cfg['repo'].',镜像 '.count($cfg['mirrors']).' 个', '保存成功', $DB);
+ json_exit('保存成功', [
+ 'qk' => 1,
+ 'repo' => $cfg['repo'],
+ 'mirrors' => $cfg['mirrors'],
+ 'has_token' => $cfg['github_token']!=='' ? 1 : 0,
+ ]);
+ return;
+}
+if($egn=='upcheck') {
+ include_once("../MPHX/BL.php");
+ include_once("../MPHX/github_updater.php");
+ include("../cf_up.php");
+ // 纯数据载荷,前端直接取字段(与 egn=mnbt 的风格一致)
+ exit(json_encode(mnbt_updater_check(!empty($_POST['force']), 60), JSON_UNESCAPED_UNICODE));
+ return;
+}
+return;
diff --git a/admin/update.php b/admin/update.php
index 3da7043..39d33b9 100644
--- a/admin/update.php
+++ b/admin/update.php
@@ -1,8 +1,11 @@
<?php
include("../MPHX/common.php");
include("../MPHX/BL.php");
-include("../MPHX/SQ.php");
+include_once("../MPHX/github_updater.php");
include("../cf_up.php");
$title = 'MN宝塔主机系统更新';
mnbt_admin_require_login();
+// 检查一次更新,两套模板共用同一份数据;recheck=1 时跳过缓存重新问 GitHub(页面最多 60 秒问一次)
+$mnbt_update = mnbt_updater_check(!empty($_GET['recheck']), 60);
+$mnbt_upcfg = mnbt_updater_config();
mnbt_admin_render('update');
diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md
index 8132484..4c170fc 100644
--- a/docs/guide/changelog.md
+++ b/docs/guide/changelog.md
@@ -1,10 +1,52 @@
---
title: 更新日志
-description: MNBT 版本更新记录(V1.83 ~ V1.60)
+description: MNBT 版本更新记录(V1.85 ~ V1.60)
---
# 更新日志
+## V1.85
+
+**外部主机 API 协议兼容开关**
+
+- 新增全局开关 `MN_config.api_compat`:`0`=新版协议(1.83+ 严格,默认),`1`=兼容 1.81 对接模块
+- 后台入口:系统设置 → API 设置 → 「对外 API 协议模式」(`egn=setapicompat`);代码侧统一走 `mnbt_api_compat_mode()`(`MPHX/function.php`)
+- 兼容模式只放宽 1.83+ 新增的**行为约束**(跨节点归属校验、`kt` 站点名按 `max(id)+1`、`zjmode` 缺参清零、`xf` 真值解停、鉴权失败老文案),传输包络与响应结构两个版本一直一致
+- 老版 `kt` 里恒真的「主机已存在」表达式与 `tz` 的资产级联删除属 bug/新能力,兼容模式刻意不还原
+- 详见 [外部对接 API](/api/external)
+
+**系统更新改造:改走 GitHub Release**
+
+- 更新不再回连自建更新服务器(`check.php`),也不再校验 `authcode`;统一从 GitHub Release 拉包覆盖站点
+- 新增 `MPHX/github_updater.php`(纯函数库,受 `IN_CRONLITE` 保护),承载配置读写、Release 元数据获取、流式下载与安全校验
+- 后台入口:`admin/api/gg.php`(`egn=update` 执行更新)、`admin/api/update_setting.php`(`egn=upcheck` 检查、`egn=upset` 保存设置);`admin/api/bt.php`(`egn=mnbt`)改用新检查器
+- 界面:`admin/update.php` + `templates/default/admin/update.php`、`templates/tdesign/admin/update.php`(含 SPA `UpdateView.vue`)展示最新可用版本、Release 正文作为更新日志、包来源与下载通道,并提供「更新设置」区块(仓库 owner/repo、镜像增删、可选 Token)
+
+**包来源优先级**
+
+1. Release 自定义 `.zip` 附件(`zip` 内容即站点根)
+2. 回落 GitHub 自动源码包(codeload),解压时剥掉顶层目录使内容对齐站点根
+
+**下载通道(多候选)**
+
+- 每个来源依次尝试:`github.com` 直连 → 配置镜像(原地址前加 `https://镜像/` 前缀)
+- Release 元数据接口(`api.github.com`)也按同样顺序走镜像兜底,否则国内环境直连不通时明明有镜像却检查不到新版本;Token 只随直连发出,不会带给镜像
+- 仓库与镜像在后台「更新设置」配置,默认 `1181469655/MNBT` + `https://gh-proxy.com/`,写入 `cf_up.php` 的 `update` 段(其余键原样保留)
+- Token 仅用于提升 GitHub API 速率,只以「是否已设置」回显,不随下载请求发给镜像,也不明文存储回显
+
+**安全与可靠性**
+
+- 下载/API 地址 host 白名单:github.com、api.github.com、codeload.github.com、objects.githubusercontent.com 及配置镜像域;解析到内网/保留 IP 一律拒绝(防 SSRF)
+- 流式下载:校验 `Content-Length` 与实际字节、`PK\x03\x04` zip 魔数,失败即删临时文件
+- 解压前扫描 ZipArchive 条目名,拒绝绝对路径、盘符、`..` 段
+- 覆盖前备份并在完成后还原 `config.php`、`cf_up.php`、`MPHX/SQ.php`、`install/install.lock`、`api/cookie/`;包自带的 `install.lock` 若站点原本没有会被删除,避免被判定为未安装
+- `set_time_limit(600)` + `ignore_user_abort` + `register_shutdown_function` 兜底:任何中断路径都会还原改名后的后台目录、还原本地配置并清理临时包
+- 升级 SQL 仅执行包内 `update/update.sql`(通过 `$dbconfig` 连接),执行成功后删除该文件与 `update/` 目录
+
+**发版运维约定**
+
+> 发版时请在 GitHub Release 挂载 zip 附件(内容即为站点根目录文件),否则将走源码包回落路径(自动剥顶层目录)。附件请确保不含本地配置文件的覆盖内容,运行时配置由更新流程的备份还原机制保护。
+
## V1.83(当前)
**Docker 容器托管**
diff --git a/templates/default/admin/update.php b/templates/default/admin/update.php
index 903f5d5..df20f1a 100644
--- a/templates/default/admin/update.php
+++ b/templates/default/admin/update.php
@@ -1,81 +1,227 @@
<?php mnbt_admin_include('head'); ?>
<?php
-$gxtj = array(
-'url' => $_SERVER['HTTP_HOST'],
-'authcode' => $authcode,
-'ver' => $WEBQB,
-);
-$result = send_post($mn_conf['aet'].'://'.$mn_conf['url'].':'.$mn_conf['port'].'/check.php',$gxtj);
-$content=json_decode($result, true);
-$total ='V'.sprintf( "%.2f ",$WEBQB/1000);
+// $mnbt_update / $mnbt_upcfg 由 admin/update.php 服务端检查后注入
+$upd_ok = !empty($mnbt_update['ok']);
+$upd_can = !empty($mnbt_update['can_update']);
+$upd_log = trim((string)($mnbt_update['body'] ?? ''));
+$upd_mirror_txt = implode("\n", $mnbt_upcfg['mirrors']);
+$upd_token_set = !empty($mnbt_update['has_token']);
?>
-<body>
-<div class="container-fluid p-t-16">
+<style>
+/* 系统更新页:直角蓝白,不用蓝底色块与装饰竖条 */
+.mn-upd-page { max-width: 900px; margin: 0 auto; padding: 16px 14px 40px; }
+.mn-upd-card { background: #fff; border: 1px solid #e8ecf1; border-radius: 0; margin-bottom: 14px; }
+.mn-upd-hd { padding: 14px 18px; border-bottom: 1px solid #eef1f5; font-size: 15px; font-weight: 600; color: #0f172a; }
+.mn-upd-hd .mdi { color: #2563eb; margin-right: 6px; }
+.mn-upd-bd { padding: 18px; }
+.mn-upd-ver { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
+.mn-upd-ver-item { flex: 1; min-width: 180px; border: 1px solid #e8ecf1; border-radius: 0; padding: 14px 16px; }
+.mn-upd-ver-num { font-size: 22px; font-weight: 700; color: #1e293b; line-height: 1.2; }
+.mn-upd-ver-num.is-new { color: #2563eb; }
+.mn-upd-ver-label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
+.mn-upd-tip { font-size: 13px; color: #475569; line-height: 1.8; margin-bottom: 14px; }
+.mn-upd-tip.bad { color: #b42318; }
+.mn-upd-kv { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
+.mn-upd-kv td { font-size: 13px; padding: 7px 0; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
+.mn-upd-kv td:first-child { color: #94a3b8; width: 110px; white-space: nowrap; }
+.mn-upd-kv td:last-child { color: #1e293b; word-break: break-all; }
+.mn-upd-log { background: #f8fafc; border: 1px solid #eef1f5; padding: 12px 14px; font-size: 12px; line-height: 1.8; color: #334155; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; margin: 0; border-radius: 0; }
+.mn-upd-log-title { font-size: 13px; font-weight: 600; color: #334155; margin: 0 0 8px; }
+.mn-upd-actions { margin-top: 16px; display: flex; gap: 10px; }
+.mn-upd-actions .btn { border-radius: 0; font-weight: 500; padding: 9px 18px; }
+.mn-upd-field { margin-bottom: 16px; }
+.mn-upd-field > label { display: block; font-size: 13px; font-weight: 500; color: #334155; margin-bottom: 6px; }
+.mn-upd-field .form-control { border-radius: 0; border-color: #e2e8f0; font-size: 13px; }
+.mn-upd-field .form-control:focus { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(37, 99, 235, .1); }
+.mn-upd-field small { display: block; margin-top: 6px; font-size: 12px; color: #94a3b8; line-height: 1.7; }
+.mn-upd-row { display: flex; gap: 8px; margin-bottom: 8px; }
+.mn-upd-row .form-control { flex: 1; }
+.mn-upd-row .btn { border-radius: 0; flex-shrink: 0; }
+.mn-upd-note { background: #f8fafc; border: 1px solid #eef1f5; padding: 12px 14px; font-size: 12px; color: #64748b; line-height: 1.8; }
+.mn-upd-note code { background: #fff; padding: 1px 4px; border: 1px solid #e8ecf1; }
+</style>
-<div class="row">
- <div class="col-lg-8">
- <div class="card">
- <header class="card-header"><div class="card-title"><?=$content['msg']?></div></header>
- <div class="card-body">
-
-<div class="row">
- <div class="col-md-6">
- <div class="card bg-primary text-white">
- <div class="card-body clearfix">
- <div class="flex-box">
- <span class="img-avatar img-avatar-48 bg-translucent"><i class="mdi mdi-dns fs-22"></i></span>
- <span class="fs-22 lh-22"><?=$total?></span>
- </div>
- <div class="text-right">系统版本</div>
+<div class="mn-upd-page">
+
+ <div class="mn-upd-card">
+ <div class="mn-upd-hd"><i class="mdi mdi-cloud-download"></i>检查更新</div>
+ <div class="mn-upd-bd">
+ <div class="mn-upd-ver">
+ <div class="mn-upd-ver-item">
+ <div class="mn-upd-ver-num"><?=htmlspecialchars($mnbt_update['current'])?></div>
+ <div class="mn-upd-ver-label">当前版本</div>
</div>
+ <div class="mn-upd-ver-item">
+ <div class="mn-upd-ver-num <?=($upd_ok && $upd_can) ? 'is-new' : ''?>"><?=htmlspecialchars($upd_ok ? $mnbt_update['latest'] : '—')?></div>
+ <div class="mn-upd-ver-label">最新可用版本<?=($upd_ok && !empty($mnbt_update['published_at'])) ? '(' . htmlspecialchars(substr((string)$mnbt_update['published_at'], 0, 10)) . ')' : ''?></div>
+ </div>
+ </div>
+
+<?php if (!$upd_ok): ?>
+ <div class="mn-upd-tip bad"><i class="mdi mdi-alert-circle-outline"></i> 检查更新失败:<?=htmlspecialchars($mnbt_update['error'])?>(仓库 <?=htmlspecialchars($mnbt_update['repo'])?>)</div>
+<?php elseif ($upd_can): ?>
+ <div class="mn-upd-tip"><i class="mdi mdi-arrow-up-bold-circle"></i> 发现新版本,可执行在线覆盖更新。</div>
+<?php else: ?>
+ <div class="mn-upd-tip"><i class="mdi mdi-bookmark-check"></i> 当前已是最新版本,无需更新。</div>
+<?php endif; ?>
+
+ <table class="mn-upd-kv">
+ <tr><td>更新仓库</td><td><?=htmlspecialchars($mnbt_update['repo'])?></td></tr>
+ <tr><td>包来源</td><td><?=htmlspecialchars($upd_ok ? $mnbt_update['source_label'] : '—')?><?=($upd_ok && !empty($mnbt_update['asset_size'])) ? '(' . number_format($mnbt_update['asset_size'] / 1048576, 1) . ' MB)' : ''?></td></tr>
+ <tr><td>下载通道</td><td>github.com 直连<?=!empty($mnbt_update['mirrors']) ? ',失败后依次尝试:' . htmlspecialchars(implode('、', $mnbt_update['mirrors'])) : ''?></td></tr>
+<?php if ($upd_ok && !empty($mnbt_update['asset_url'])): ?>
+ <tr><td>附件地址</td><td><?=htmlspecialchars($mnbt_update['asset_url'])?></td></tr>
+<?php endif; ?>
+<?php if (!empty($mnbt_update['fallback'])): ?>
+ <tr><td>检查方式</td><td>latest 接口不可用,已改用发布列表取最高版本</td></tr>
+<?php endif; ?>
+ </table>
+
+<?php if ($upd_ok): ?>
+ <div class="mn-upd-actions">
+<?php if ($upd_can): ?>
+ <button class="btn btn-primary" type="button" onclick="up()"><i class="mdi mdi-update"></i> 立刻更新</button>
+<?php endif; ?>
+ <a class="btn btn-outline-secondary" href="update.php?recheck=1"><i class="mdi mdi-refresh"></i> 重新检查</a>
</div>
+<?php else: ?>
+ <div class="mn-upd-actions">
+ <a class="btn btn-outline-secondary" href="update.php?recheck=1"><i class="mdi mdi-refresh"></i> 重新检查</a>
+ </div>
+<?php endif; ?>
+ </div>
+ </div>
+
+ <div class="mn-upd-card">
+ <div class="mn-upd-hd"><i class="mdi mdi-text-box-outline"></i>更新日志</div>
+ <div class="mn-upd-bd">
+<?php if ($upd_log !== ''): ?>
+ <pre class="mn-upd-log"><?=htmlspecialchars($upd_log)?></pre>
+<?php else: ?>
+ <p class="mn-upd-tip">该版本发布没有填写更新说明。</p>
+<?php endif; ?>
</div>
-
- <div class="col-md-6">
- <div class="card bg-purple text-white">
- <div class="card-body clearfix">
- <div class="flex-box">
- <span class="img-avatar img-avatar-48 bg-translucent"><i class="mdi mdi-image-filter-drama fs-22"></i></span>
- <span class="fs-22 lh-22"><?=$content['ver']?></span>
+ </div>
+
+ <div class="mn-upd-card">
+ <div class="mn-upd-hd"><i class="mdi mdi-cog-outline"></i>更新设置</div>
+ <div class="mn-upd-bd">
+ <div class="mn-upd-field">
+ <label for="upd_repo">GitHub 仓库</label>
+ <input type="text" class="form-control" id="upd_repo" value="<?=htmlspecialchars($mnbt_upcfg['repo'])?>" placeholder="owner/repo" maxlength="140">
+ <small>格式 <code>owner/repo</code>,更新包只能从这个仓库的 Release 里取。</small>
+ </div>
+
+ <div class="mn-upd-field">
+ <label>下载镜像</label>
+ <div id="upd_mirrors">
+<?php foreach ($mnbt_upcfg['mirrors'] as $m): ?>
+ <div class="mn-upd-row">
+ <input type="text" class="form-control upd-mirror-input" value="<?=htmlspecialchars($m)?>" placeholder="https://gh-proxy.com/" maxlength="160">
+ <button class="btn btn-outline-danger" type="button" onclick="updDelMirror(this)"><i class="mdi mdi-close"></i></button>
</div>
- <div class="text-right">最新版本</div>
+<?php endforeach; ?>
</div>
+ <button class="btn btn-outline-secondary" type="button" onclick="updAddMirror()"><i class="mdi mdi-plus"></i> 添加镜像</button>
+ <small>按顺序依次尝试:先 github.com 直连,再按上面的顺序走镜像。镜像地址填 <code>https://gh-proxy.com/</code> 这种带协议的形式,会在原地址前面加前缀。</small>
</div>
- </div>
-
- <?php if($content['code']=='1'){?>
-<div class="container-fluid p-t-15">
- <button class="btn btn-primary form-control" type="button" onclick="up()"><label><i class="mdi mdi-arrow-up-bold-circle"></i></label>立刻更新</button>
- </div>
- <?php }?>
- </div>
- <div class="card-header"></div>
- <div class="card-body">
- <p><small><?=$content['uplog']?></small></p>
+
+ <div class="mn-upd-field">
+ <label for="upd_token">GitHub Token(可选)</label>
+ <input type="password" class="form-control" id="upd_token" placeholder="<?=($upd_token_set ? '已设置,留空表示不修改' : '仅用于提高 API 速率限制,可留空')?>" autocomplete="new-password" maxlength="128">
+<?php if ($upd_token_set): ?>
+ <div class="mn-upd-row" style="margin-top:8px">
+ <label style="font-size:12px;color:#64748b;font-weight:normal;margin:0;padding-top:8px;">
+ <input type="checkbox" id="upd_clear_token"> 清除已保存的 Token
+ </label>
</div>
-
+<?php endif; ?>
+ <small>Token 只用于调用 GitHub API,不会跟着下载请求发给镜像;页面不会回显明文。</small>
+ </div>
+
+ <div class="mn-upd-actions">
+ <button class="btn btn-primary" type="button" onclick="saveUpdSetting()"><i class="mdi mdi-content-save-outline"></i> 保存更新设置</button>
+ </div>
+
+ <div class="mn-upd-note" style="margin-top:16px;">
+ <b>更新说明:</b>更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
+ <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
+ 包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作,更新过程中不要关闭页面。
</div>
</div>
</div>
+
</div>
<script type="text/javascript">
-function up() {
-msloading() // 加载显示
-let data = {};
-data["gn"]="update";
-$.post('./ajax.php', data, function (date) {
-var jsoe= JSON.parse(date);
-var qk= jsoe.code
-if(qk=='更新成功~请手动刷新页面'){
-msalert(1,qk,4000);
-msloadingde(); // 隐藏
-}else{
-msalert(4,qk,4000);
-msloadingde(); // 隐藏
-
+function updAddMirror() {
+ var box = document.getElementById('upd_mirrors');
+ var div = document.createElement('div');
+ div.className = 'mn-upd-row';
+ div.innerHTML = '<input type="text" class="form-control upd-mirror-input" placeholder="https://gh-proxy.com/" maxlength="160">'
+ + '<button class="btn btn-outline-danger" type="button" onclick="updDelMirror(this)"><i class="mdi mdi-close"></i></button>';
+ box.appendChild(div);
+ div.querySelector('input').focus();
}
-})
+function updDelMirror(btn) {
+ var row = btn.parentNode;
+ var box = document.getElementById('upd_mirrors');
+ if (box.children.length <= 1) {
+ row.querySelector('input').value = '';
+ return;
+ }
+ box.removeChild(row);
+}
+function saveUpdSetting() {
+ var mirrors = [];
+ var list = document.querySelectorAll('#upd_mirrors .upd-mirror-input');
+ for (var i = 0; i < list.length; i++) {
+ var v = $.trim(list[i].value);
+ if (v !== '' && mirrors.indexOf(v) === -1) mirrors.push(v);
+ }
+ var data = {};
+ data['gn'] = 'upset';
+ data['repo'] = $.trim(document.getElementById('upd_repo').value);
+ data['mirrors'] = mirrors.join('\n');
+ var tk = document.getElementById('upd_token');
+ if (tk && tk.value !== '') data['github_token'] = $.trim(tk.value);
+ var cl = document.getElementById('upd_clear_token');
+ if (cl && cl.checked) data['clear_token'] = '1';
+ msloading();
+ $.post('./ajax.php', data, function (date) {
+ msloadingde();
+ var jsoe = JSON.parse(date);
+ if (jsoe.qk == 1 || jsoe.code == '保存成功') {
+ msalert(1, '保存成功', 2500);
+ setTimeout(function () { window.location.href = 'update.php'; }, 1200);
+ } else {
+ msalert(4, jsoe.code || '保存失败', 4000);
+ }
+ }).fail(function () {
+ msloadingde();
+ msalert(4, '网络错误,保存失败', 3000);
+ });
+}
+function up() {
+ if (!window.confirm('将用 GitHub Release 的包覆盖当前站点文件,过程可能需要几分钟,期间请勿关闭页面。确认开始更新?')) return;
+ msloading();
+ let data = {};
+ data["gn"] = "update";
+ $.post('./ajax.php', data, function (date) {
+ msloadingde();
+ var jsoe = JSON.parse(date);
+ var qk = jsoe.code;
+ if (jsoe.qk == 1 || qk == '更新成功~请手动刷新页面') {
+ var txt = '更新成功~请手动刷新页面';
+ if (jsoe.source) txt += '|' + jsoe.source + (jsoe.via ? '(' + jsoe.via + ')' : '');
+ msalert(1, txt, 6000);
+ } else {
+ msalert(4, qk || '更新失败', 8000);
+ }
+ }).fail(function () {
+ msloadingde();
+ msalert(4, '更新请求中断,请刷新后台确认站点是否正常;本地配置与后台目录在中断时会自动还原', 8000);
+ });
}
</script>
</body>
-</html>
\ No newline at end of file
+</html>
diff --git a/templates/tdesign/admin/update.php b/templates/tdesign/admin/update.php
index a11b2f9..67bf652 100644
--- a/templates/tdesign/admin/update.php
+++ b/templates/tdesign/admin/update.php
@@ -1,24 +1,53 @@
<?php
-// 系统更新:服务器端检查更新(与默认主题 update.php 一致),注入 boot
+// 系统更新:服务器端检查更新(admin/update.php 已检查并注入 $mnbt_update / $mnbt_upcfg),输出注入 boot
$td_entry = 'update';
$td_hash = '#/update';
$td_inject = [];
-// 当前版本
-$td_inject['currentVersion'] = isset($WEBQB) ? 'V' . sprintf('%.2f', $WEBQB / 1000) : 'V0.00';
-
-// 服务器端检查更新
-if (isset($mn_conf['url']) && $mn_conf['url'] && function_exists('send_post')) {
- $gxtj = array(
- 'url' => $_SERVER['HTTP_HOST'] ?? '',
- 'authcode' => $authcode ?? '',
- 'ver' => $WEBQB ?? 0,
- );
- $result = send_post($mn_conf['aet'] . '://' . $mn_conf['url'] . ':' . $mn_conf['port'] . '/check.php', $gxtj);
- $content = json_decode($result, true);
- if (is_array($content)) {
- $td_inject['updateInfo'] = $content;
+if (!isset($mnbt_update) || !is_array($mnbt_update)) {
+ // 直接访问本文件时的兜底(正常入口都会带上来)
+ if (!function_exists('mnbt_updater_check')) {
+ include_once dirname(__DIR__, 3) . '/MPHX/github_updater.php';
}
+ $mnbt_update = mnbt_updater_check(!empty($_GET['recheck']), 60);
+}
+if (!isset($mnbt_upcfg) || !is_array($mnbt_upcfg)) {
+ $mnbt_upcfg = mnbt_updater_config();
}
+$td_inject['currentVersion'] = $mnbt_update['current'];
+
+// code/ver/msg/uplog 是给已编译的 dist 用的老字段,保持原语义不能改
+$td_info = [
+ 'code' => $mnbt_update['ok'] != 1 ? '-1' : ($mnbt_update['can_update'] == 1 ? '1' : '0'),
+ 'ver' => $mnbt_update['ok'] == 1 ? $mnbt_update['latest'] : '-',
+ 'msg' => $mnbt_update['ok'] != 1
+ ? ('暂时无法检查更新:' . $mnbt_update['error'])
+ : ($mnbt_update['can_update'] == 1 ? '发现新版本,可执行在线覆盖更新' : '当前版本已是最新,无需更新'),
+ 'uplog' => (string)$mnbt_update['body'],
+ // 新增字段:下载来源与配置(老 dist 会忽略)
+ 'repo' => $mnbt_update['repo'],
+ 'tag' => $mnbt_update['tag'],
+ 'name' => $mnbt_update['name'],
+ 'source' => $mnbt_update['source'],
+ 'sourceLabel' => $mnbt_update['source_label'],
+ 'assetName' => $mnbt_update['asset_name'],
+ 'assetUrl' => $mnbt_update['asset_url'],
+ 'assetSize' => $mnbt_update['asset_size'],
+ 'publishedAt' => $mnbt_update['published_at'],
+ 'canUpdate' => (int)$mnbt_update['can_update'],
+ 'error' => $mnbt_update['error'],
+ 'fallback' => (int)$mnbt_update['fallback'],
+ 'currentVer' => $mnbt_update['current'],
+ 'mirrors' => array_values((array)$mnbt_update['mirrors']),
+];
+$td_inject['updateInfo'] = $td_info;
+
+// 更新设置:Token 只回显是否已设置,绝不回显明文
+$td_inject['updaterConfig'] = [
+ 'repo' => $mnbt_upcfg['repo'],
+ 'mirrors' => array_values($mnbt_upcfg['mirrors']),
+ 'hasToken' => $mnbt_upcfg['github_token'] !== '' ? 1 : 0,
+];
+
include __DIR__ . '/_spa_boot.php';
diff --git a/templates/tdesign/spa/src/admin/api/dashboard.js b/templates/tdesign/spa/src/admin/api/dashboard.js
index cb06021..73d29c8 100644
--- a/templates/tdesign/spa/src/admin/api/dashboard.js
+++ b/templates/tdesign/spa/src/admin/api/dashboard.js
@@ -17,6 +17,16 @@ export function systemUpdate() {
return apiGn('update', {})
}
+/** 重新检查更新(跳过服务端缓存,返回 GitHub Release 检查结果原始载荷) */
+export function updaterCheck(force = 1) {
+ return apiGn('upcheck', { force }, { silent: true })
+}
+
+/** 保存更新设置(仓库 / 镜像 / 可选 Token) */
+export function saveUpdaterConfig(data) {
+ return apiGn('upset', data)
+}
+
/** 系统修复 */
export function systemRepair(xx, xe) {
return apiGn('xtxf', { xx, xe })
diff --git a/templates/tdesign/spa/src/admin/views/UpdateView.vue b/templates/tdesign/spa/src/admin/views/UpdateView.vue
index 4eb0741..5f1f0dd 100644
--- a/templates/tdesign/spa/src/admin/views/UpdateView.vue
+++ b/templates/tdesign/spa/src/admin/views/UpdateView.vue
@@ -62,6 +62,16 @@
<i class="mdi mdi-update"></i> 立刻更新
</t-button>
</div>
+ <div class="td-set-card-bd">
+ <h5 class="upd-section">下载来源</h5>
+ <table class="upd-kv">
+ <tr><td>更新仓库</td><td>{{ info.repo }}</td></tr>
+ <tr><td>包来源</td><td>{{ info.sourceLabel }}<template v-if="info.assetSize">({{ mb(info.assetSize) }})</template></td></tr>
+ <tr><td>下载通道</td><td>github.com 直连<template v-if="info.mirrors && info.mirrors.length">,失败后依次尝试:{{ info.mirrors.join('、') }}</template></td></tr>
+ <tr v-if="info.publishedAt"><td>发布时间</td><td>{{ String(info.publishedAt).slice(0, 10) }}</td></tr>
+ <tr v-if="info.fallback"><td>检查方式</td><td>latest 接口不可用,已改用发布列表取最高版本</td></tr>
+ </table>
+ </div>
<div class="td-set-card-bd" v-if="info.uplog">
<h5 class="upd-section">更新日志</h5>
<pre class="td-code-block upd-log">{{ info.uplog }}</pre>
@@ -79,33 +89,92 @@
<p>{{ info.msg || '当前版本已是最新,无需更新' }}</p>
</div>
</div>
+ <div class="td-set-card-bd">
+ <h5 class="upd-section">下载来源</h5>
+ <table class="upd-kv">
+ <tr><td>更新仓库</td><td>{{ info.repo }}</td></tr>
+ <tr><td>包来源</td><td>{{ info.sourceLabel }}</td></tr>
+ <tr><td>下载通道</td><td>github.com 直连<template v-if="info.mirrors && info.mirrors.length">,失败后依次尝试:{{ info.mirrors.join('、') }}</template></td></tr>
+ </table>
+ </div>
<div class="td-set-card-bd" v-if="info.uplog">
<h5 class="upd-section">版本日志</h5>
<pre class="td-code-block upd-log">{{ info.uplog }}</pre>
</div>
</div>
- <!-- 离线模式 -->
+ <!-- 检查失败 -->
<div v-else class="td-set-card">
<div class="td-set-card-hd">
<div class="td-set-icon" style="background: #f5f6f8; color: #8c8c8c">
- <i class="mdi mdi-account-off"></i>
+ <i class="mdi mdi-cloud-off"></i>
</div>
<div>
- <h4>{{ info.msg || '离线模式' }}</h4>
- <p>离线模式不提供更新服务</p>
+ <h4>{{ info.msg || '暂时无法检查更新' }}</h4>
+ <p>更新只走 GitHub Release,请检查服务器能否访问 api.github.com 或更新设置里的仓库与镜像</p>
</div>
</div>
</div>
</template>
+
+ <!-- 更新设置 -->
+ <div class="td-set-card">
+ <div class="td-set-card-hd">
+ <div class="td-set-icon" style="background: #eff6ff; color: #2563eb">
+ <i class="mdi mdi-cog-outline"></i>
+ </div>
+ <div>
+ <h4>更新设置</h4>
+ <p>GitHub 仓库、下载镜像与可选 Token</p>
+ </div>
+ </div>
+ <div class="td-set-card-bd">
+ <div class="upd-field">
+ <label>GitHub 仓库</label>
+ <t-input v-model="form.repo" placeholder="owner/repo" :maxlength="140" />
+ <p class="upd-hint">更新包只能从这个仓库的 Release 里取。</p>
+ </div>
+ <div class="upd-field">
+ <label>下载镜像</label>
+ <div v-for="(m, i) in form.mirrors" :key="i" class="upd-mirror-row">
+ <t-input v-model="form.mirrors[i]" placeholder="https://gh-proxy.com/" :maxlength="160" />
+ <t-button theme="default" variant="outline" @click="delMirror(i)">
+ <i class="mdi mdi-close"></i>
+ </t-button>
+ </div>
+ <t-button theme="default" variant="outline" @click="addMirror">
+ <i class="mdi mdi-plus"></i> 添加镜像
+ </t-button>
+ <p class="upd-hint">先 github.com 直连,再按上面的顺序依次尝试镜像;镜像地址会在原始 GitHub 地址前面加前缀。</p>
+ </div>
+ <div class="upd-field">
+ <label>GitHub Token(可选)</label>
+ <t-input v-model="form.token" type="password" :placeholder="form.hasToken ? '已设置,留空表示不修改' : '仅用于提高 API 速率限制,可留空'" :maxlength="128" />
+ <label v-if="form.hasToken" class="upd-clear">
+ <input type="checkbox" v-model="form.clearToken" /> 清除已保存的 Token
+ </label>
+ <p class="upd-hint">Token 只用于调用 GitHub API,不会跟着下载请求发给镜像,页面也不回显明文。</p>
+ </div>
+ <div class="upd-actions">
+ <t-button theme="primary" :loading="saving" @click="saveSetting">
+ <i class="mdi mdi-content-save-outline"></i> 保存更新设置
+ </t-button>
+ </div>
+ <p class="upd-note">
+ 更新会直接用 GitHub Release 的包覆盖站点文件,覆盖前会备份并还原
+ <code>config.php</code>、<code>cf_up.php</code>、<code>MPHX/SQ.php</code>、<code>install/install.lock</code>、<code>api/cookie/</code>;
+ 包里若带 <code>update/update.sql</code> 会自动执行。请在维护时段操作。
+ </p>
+ </div>
+ </div>
</div>
</div>
</template>
<script setup>
-import { ref, computed } from 'vue'
+import { ref, reactive, computed } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
-import { systemUpdate } from '@/admin/api/dashboard'
+import { systemUpdate, updaterCheck, saveUpdaterConfig } from '@/admin/api/dashboard'
const boot = window.__TD_BOOT__ || {}
@@ -122,6 +191,32 @@ const latestVersion = computed(() => {
})
const updating = ref(false)
+const saving = ref(false)
+
+const cfg = boot.updaterConfig || {}
+const form = reactive({
+ repo: cfg.repo || '',
+ mirrors: Array.isArray(cfg.mirrors) ? cfg.mirrors.slice() : [],
+ token: '',
+ hasToken: Number(cfg.hasToken) === 1,
+ clearToken: false,
+})
+
+function mb(bytes) {
+ return (Number(bytes) / 1048576).toFixed(1) + ' MB'
+}
+
+function addMirror() {
+ form.mirrors.push('')
+}
+
+function delMirror(i) {
+ if (form.mirrors.length <= 1) {
+ form.mirrors[0] = ''
+ return
+ }
+ form.mirrors.splice(i, 1)
+}
async function doUpdate() {
updating.value = true
@@ -137,9 +232,63 @@ async function doUpdate() {
}
}
-function recheck() {
- // 重新检查需要重新请求 PHP(服务器端 send_post)
- window.location.reload()
+async function recheck() {
+ const r = await updaterCheck()
+ if (r.ok && r.data && typeof r.data === 'object') {
+ info.value = normalizeInfo(r.data)
+ if (r.data.current) currentVersion.value = r.data.current
+ MessagePlugin.success('已重新检查')
+ } else {
+ // 接口不可用时退回整页重查
+ window.location.href = 'update.php?recheck=1'
+ }
+}
+
+async function saveSetting() {
+ const mirrors = form.mirrors.map((m) => String(m || '').trim()).filter((m, i, arr) => m !== '' && arr.indexOf(m) === i)
+ const payload = { repo: form.repo.trim(), mirrors }
+ if (form.token && form.token.trim() !== '') payload.github_token = form.token.trim()
+ if (form.clearToken) payload.clear_token = '1'
+ saving.value = true
+ const r = await saveUpdaterConfig(payload)
+ saving.value = false
+ if (r.ok) {
+ MessagePlugin.success('保存成功')
+ form.token = ''
+ form.clearToken = false
+ form.mirrors = mirrors.length ? mirrors.slice() : ['']
+ setTimeout(() => window.location.reload(), 1200)
+ } else {
+ MessagePlugin.error(r.message || '保存失败')
+ }
+}
+
+// 后端 upcheck 返回的是原始检查结果,转成页面用的 updateInfo 结构
+function normalizeInfo(d) {
+ const ok = Number(d.ok) === 1
+ const can = Number(d.can_update) === 1
+ return {
+ code: ok ? (can ? '1' : '0') : '-1',
+ ver: ok ? d.latest : '-',
+ msg: !ok
+ ? '暂时无法检查更新:' + (d.error || '')
+ : can
+ ? '发现新版本,可执行在线覆盖更新'
+ : '当前版本已是最新,无需更新',
+ uplog: d.body || '',
+ repo: d.repo,
+ tag: d.tag,
+ name: d.name,
+ source: d.source,
+ sourceLabel: d.source_label,
+ assetName: d.asset_name,
+ assetUrl: d.asset_url,
+ assetSize: d.asset_size,
+ publishedAt: d.published_at,
+ fallback: d.fallback,
+ mirrors: d.mirrors || [],
+ currentVer: d.current,
+ }
}
</script>
@@ -203,4 +352,64 @@ function recheck() {
overflow-y: auto;
margin: 0;
}
+.upd-kv {
+ width: 100%;
+ border-collapse: collapse;
+}
+.upd-kv td {
+ font-size: 13px;
+ padding: 6px 0;
+ border-bottom: 1px solid #f1f5f9;
+ color: var(--td-text);
+ word-break: break-all;
+}
+.upd-kv td:first-child {
+ color: var(--td-text-secondary);
+ width: 96px;
+ white-space: nowrap;
+}
+.upd-field {
+ margin-bottom: 16px;
+}
+.upd-field > label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--td-text);
+ margin-bottom: 6px;
+}
+.upd-mirror-row {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.upd-clear {
+ display: block;
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--td-text-secondary);
+}
+.upd-hint {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--td-text-secondary);
+ line-height: 1.7;
+}
+.upd-actions {
+ margin-top: 6px;
+}
+.upd-note {
+ margin: 16px 0 0;
+ padding: 12px 14px;
+ background: #f8fafc;
+ border: 1px solid #eef1f5;
+ font-size: 12px;
+ line-height: 1.8;
+ color: var(--td-text-secondary);
+}
+.upd-note code {
+ background: #fff;
+ border: 1px solid #e8ecf1;
+ padding: 1px 4px;
+}
</style>