better-staridc-MNBT
1<?php
2if (!defined('IN_CRONLITE')) exit();
3/**
4 * 系统更新:从 GitHub Release 拉包覆盖更新(V1.85 起替代自建更新服务器 check.php)
5 *
6 * 为什么这样写:更新包来自站外,配置文件 cf_up.php 里又能改仓库与镜像地址,
7 * 所以这里内置域名白名单 + 解析后 IP 校验,防止配置被人改成内网地址形成 SSRF。
8 * 只定义函数,不产生任何输出。
9 */
10
11/** 站点根目录(带尾斜杠) */
12function mnbt_updater_root()
13{
14 return defined('ROOT') ? ROOT : rtrim(str_replace('\\', '/', dirname(__DIR__)), '/') . '/';
15}
16
17/** 默认配置:仓库与镜像 */
18function mnbt_updater_defaults()
19{
20 return ['repo' => '1181469655/MNBT', 'mirrors' => ['https://gh-proxy.com/'], 'github_token' => ''];
21}
22
23/** 读 cf_up.php 的 $mn_conf(页面已 include 时直接复用,避免重复读文件) */
24function mnbt_updater_read_conf()
25{
26 if (isset($GLOBALS['mn_conf']) && is_array($GLOBALS['mn_conf'])) {
27 return $GLOBALS['mn_conf'];
28 }
29 $mn_conf = [];
30 $cf = mnbt_updater_root() . 'cf_up.php';
31 if (is_file($cf)) {
32 include $cf;
33 }
34 return is_array($mn_conf) ? $mn_conf : [];
35}
36
37/** owner/repo 规范化,非法返回空串 */
38function mnbt_updater_norm_repo($repo)
39{
40 $repo = trim((string)$repo);
41 $repo = preg_replace('#^https?://github\.com/#i', '', $repo);
42 $repo = trim($repo, " \t\n\r/");
43 if (preg_match('#^([A-Za-z0-9._\-]{1,60})/([A-Za-z0-9._\-]{1,80})$#', $repo, $m)) {
44 // 仓库名带 .git 后缀时剥掉,GitHub API 不接受
45 return $m[1] . '/' . preg_replace('#\.git$#', '', $m[2]);
46 }
47 return '';
48}
49
50/** 镜像地址规范化:支持只填域名,统一补协议与尾斜杠;非法返回空串 */
51function mnbt_updater_norm_mirror($mirror)
52{
53 $mirror = trim((string)$mirror);
54 if ($mirror === '') return '';
55 if (!preg_match('#^https?://#i', $mirror)) {
56 if (!preg_match('#^[A-Za-z0-9.\-]{3,120}$#', $mirror)) return '';
57 $mirror = 'https://' . $mirror;
58 }
59 $mirror = rtrim($mirror, '/') . '/';
60 // 双引号写回 cf_up.php,出现引号/反斜杠/美元符会破坏配置文件
61 if (!preg_match('#^https?://[A-Za-z0-9.\-:/_]+$#', $mirror)) return '';
62 $host = strtolower((string)parse_url($mirror, PHP_URL_HOST));
63 if ($host === '' || strpos($host, '.') === false) return '';
64 return $mirror;
65}
66
67/** 更新配置:仓库、镜像列表、Token;缺省值兜底 */
68function mnbt_updater_config()
69{
70 $mn_conf = mnbt_updater_read_conf();
71 $def = mnbt_updater_defaults();
72 $up = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
73
74 $repo = isset($up['repo']) ? mnbt_updater_norm_repo($up['repo']) : '';
75 if ($repo === '') $repo = $def['repo'];
76
77 $mirrors = [];
78 $mirrors_set = false;
79 if (isset($up['mirrors'])) {
80 // 显式配置过就按配置走,包括被清空(只走 github 直连)
81 $mirrors_set = true;
82 $raw = is_array($up['mirrors']) ? $up['mirrors'] : preg_split('/\r\n|\r|\n/', (string)$up['mirrors']);
83 foreach ($raw as $m) {
84 $n = mnbt_updater_norm_mirror($m);
85 if ($n !== '' && !in_array($n, $mirrors, true)) $mirrors[] = $n;
86 }
87 }
88 if (!$mirrors_set && !$mirrors) $mirrors = $def['mirrors'];
89
90 $token = trim((string)($up['github_token'] ?? ''));
91 if ($token !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token)) $token = '';
92
93 return ['repo' => $repo, 'mirrors' => $mirrors, 'github_token' => $token];
94}
95
96/**
97 * 回写 cf_up.php 的 update 段,其余键原样保留(参照 admin/api/repair.php 的 ary_asd 写法)
98 * @param string|null $token null 表示保持原值不变
99 */
100function mnbt_updater_save_config($repo, $mirrors, $token = null)
101{
102 $repo = mnbt_updater_norm_repo($repo);
103 if ($repo === '') return ['ok' => 0, 'error' => '仓库格式不正确,应为 owner/repo'];
104
105 $clean = [];
106 foreach ((array)$mirrors as $m) {
107 $n = mnbt_updater_norm_mirror($m);
108 if ($n !== '' && !in_array($n, $clean, true)) $clean[] = $n;
109 }
110 $token_new = null;
111 if ($token !== null) {
112 $token_new = trim((string)$token);
113 if ($token_new !== '' && !preg_match('#^[A-Za-z0-9._\-]{1,128}$#', $token_new)) {
114 return ['ok' => 0, 'error' => 'Token 含非法字符,只允许字母、数字与 . _ -'];
115 }
116 }
117
118 $mn_conf = mnbt_updater_read_conf();
119 $old = isset($mn_conf['update']) && is_array($mn_conf['update']) ? $mn_conf['update'] : [];
120 $mn_conf['update'] = [
121 'repo' => $repo,
122 'mirrors' => array_values($clean),
123 'github_token' => $token_new === null ? (string)($old['github_token'] ?? '') : $token_new,
124 ];
125
126 $kr_sxy = ary_asd($mn_conf);
127 if ($kr_sxy === '') return ['ok' => 0, 'error' => '配置数据为空,已取消写入'];
128 $cf = mnbt_updater_root() . 'cf_up.php';
129 if (is_file($cf) && !is_writable($cf)) return ['ok' => 0, 'error' => 'cf_up.php 不可写,请检查文件权限'];
130 $wr = @file_put_contents($cf, '<?php $mn_conf=array(' . $kr_sxy . ');?>');
131 if ($wr === false) return ['ok' => 0, 'error' => '配置文件写入失败'];
132 $GLOBALS['mn_conf'] = $mn_conf;
133 return ['ok' => 1, 'error' => ''];
134}
135
136/** 下载/接口地址的 host 是否授权:GitHub 官方域 + 配置里的镜像域 */
137function mnbt_updater_host_allowed($host, $cfg)
138{
139 $host = strtolower(trim((string)$host));
140 if ($host === '') return false;
141 // github.com / api.github.com / codeload.github.com / *.githubusercontent.com(附件与源码包的跳转目标)
142 if (preg_match('#^([a-z0-9\-]+\.)*github(usercontent)?\.com$#', $host)) return true;
143 foreach ((array)$cfg['mirrors'] as $m) {
144 $mh = strtolower((string)parse_url($m, PHP_URL_HOST));
145 if ($mh !== '' && $mh === $host) return true;
146 }
147 return false;
148}
149
150/** IP 是否为公网可路由地址 */
151function mnbt_updater_ip_public($ip)
152{
153 return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
154}
155
156/** 域名解析结果必须全是公网地址,否则视为内网打洞 */
157function mnbt_updater_host_public($host, &$err)
158{
159 static $memo = [];
160 $host = strtolower((string)$host);
161 if (isset($memo[$host])) {
162 // 同一次请求里会反复校验同一批地址,避免重复解析
163 if ($memo[$host] === true) return true;
164 $err = $memo[$host];
165 return false;
166 }
167 $ips = @gethostbynamel($host);
168 if (!is_array($ips)) $ips = [];
169 if (function_exists('dns_get_record')) {
170 $v6 = @dns_get_record($host, DNS_AAAA);
171 if (is_array($v6)) {
172 foreach ($v6 as $r) {
173 if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
174 }
175 }
176 }
177 if (!$ips) {
178 $err = '无法解析域名 ' . $host;
179 $memo[$host] = $err;
180 return false;
181 }
182 foreach ($ips as $ip) {
183 if (!mnbt_updater_ip_public($ip)) {
184 $err = '域名 ' . $host . ' 解析到内网或保留地址,已拒绝';
185 $memo[$host] = $err;
186 return false;
187 }
188 }
189 $memo[$host] = true;
190 return true;
191}
192
193/** 完整地址安全检查:协议 + 白名单 + 解析 IP */
194function mnbt_updater_url_safe($url, $cfg, &$err)
195{
196 $url = trim((string)$url);
197 if (!preg_match('#^https?://#i', $url)) {
198 $err = '仅允许 http/https 地址';
199 return false;
200 }
201 $host = (string)parse_url($url, PHP_URL_HOST);
202 if ($host === '') {
203 $err = '地址无效';
204 return false;
205 }
206 if (!mnbt_updater_host_allowed($host, $cfg)) {
207 $err = '未授权的下载地址:' . $host;
208 return false;
209 }
210 return mnbt_updater_host_public($host, $err);
211}
212
213/** GitHub API 单次请求(返回体量很小,可以进内存) */
214function mnbt_updater_api_once($url, $cfg, $send_token)
215{
216 $out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
217 $err = '';
218 if (!mnbt_updater_url_safe($url, $cfg, $err)) {
219 $out['error'] = $err;
220 return $out;
221 }
222 $headers = [
223 'User-Agent: MNBT-Updater/1.85',
224 'Accept: application/vnd.github+json',
225 ];
226 // Token 只用于提速率,发给镜像就等于把凭据交给第三方,所以只有直连才带
227 if ($send_token && $cfg['github_token'] !== '') {
228 $headers[] = 'Authorization: Bearer ' . $cfg['github_token'];
229 }
230 $ch = curl_init($url);
231 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
232 curl_setopt($ch, CURLOPT_HEADER, false);
233 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
234 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
235 curl_setopt($ch, CURLOPT_TIMEOUT, 20);
236 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
237 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
238 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
239 $body = curl_exec($ch);
240 $cerr = curl_error($ch);
241 $out['http'] = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
242 curl_close($ch);
243 if ($body === false) {
244 $out['error'] = $cerr !== '' ? $cerr : '接口请求失败';
245 return $out;
246 }
247 if ($out['http'] < 200 || $out['http'] >= 300) {
248 $js = json_decode((string)$body, true);
249 $msg = is_array($js) && isset($js['message']) ? (string)$js['message'] : '';
250 $out['error'] = 'HTTP ' . $out['http'] . ($msg !== '' ? '(' . $msg . ')' : '');
251 return $out;
252 }
253 $js = json_decode((string)$body, true);
254 if (!is_array($js)) {
255 $out['error'] = '接口返回内容无法解析';
256 return $out;
257 }
258 $out['ok'] = 1;
259 $out['json'] = $js;
260 return $out;
261}
262
263/**
264 * 调用 GitHub API:先直连 api.github.com,失败再按镜像顺序尝试
265 * 国内环境直连 api.github.com 常常不通,但镜像可以代理它,所以这里也要有镜像兜底
266 */
267function mnbt_updater_api_get($path, $cfg)
268{
269 $out = ['ok' => 0, 'json' => null, 'http' => 0, 'error' => ''];
270 if (!function_exists('curl_init')) {
271 $out['error'] = '服务器未启用 curl 扩展';
272 return $out;
273 }
274 $direct = 'https://api.github.com' . $path;
275 $urls = [['url' => $direct, 'token' => true]];
276 foreach ($cfg['mirrors'] as $m) {
277 $urls[] = ['url' => $m . $direct, 'token' => false];
278 }
279 $errs = [];
280 foreach ($urls as $u) {
281 $r = mnbt_updater_api_once($u['url'], $cfg, $u['token']);
282 if ($r['ok']) return $r;
283 $errs[] = ($u['token'] ? '直连' : $u['url']) . ':' . $r['error'];
284 $out['http'] = $r['http'];
285 }
286 $out['error'] = '接口请求失败(' . implode(' / ', $errs) . ')';
287 return $out;
288}
289
290/**
291 * tag 规范化成项目版本号:V1.84 => 1840
292 * 与 $WEBQB 的规则一致:主版本占千位、次版本占百十位、补丁占个位(补丁最多记 9)
293 */
294function mnbt_updater_tag_to_version($tag)
295{
296 $tag = (string)$tag;
297 if (!preg_match('/(\d+)(?:[.\-](\d+))?(?:[.\-](\d+))?/', $tag, $m)) return 0;
298 $major = (int)$m[1];
299 $minor = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : 0;
300 $patch = isset($m[3]) && $m[3] !== '' ? (int)$m[3] : 0;
301 return $major * 1000 + $minor * 10 + min(9, $patch);
302}
303
304/** tag 显示名(GitHub 上的 tag 已带 V 前缀,这里只做兜底) */
305function mnbt_updater_tag_display($tag)
306{
307 $tag = trim((string)$tag);
308 if ($tag === '') return '';
309 return (preg_match('/^v/i', $tag) ? '' : 'V') . $tag;
310}
311
312/** 从 release 的 assets 里挑自定义 zip 附件 */
313function mnbt_updater_pick_asset($assets)
314{
315 if (!is_array($assets)) return ['url' => '', 'name' => '', 'size' => 0];
316 foreach ($assets as $a) {
317 if (!is_array($a)) continue;
318 $url = isset($a['browser_download_url']) ? (string)$a['browser_download_url'] : '';
319 $name = isset($a['name']) ? (string)$a['name'] : '';
320 if ($url === '') continue;
321 if (!preg_match('#\.zip$#i', $name !== '' ? $name : $url)) continue;
322 return ['url' => $url, 'name' => $name, 'size' => (int)($a['size'] ?? 0)];
323 }
324 return ['url' => '', 'name' => '', 'size' => 0];
325}
326
327/**
328 * 下载候选地址:包来源优先级为 release 自定义 zip 附件 > GitHub 自动源码包;
329 * 每个来源再按 github.com 直连、配置镜像顺序依次尝试
330 */
331function mnbt_updater_candidates($cfg, $tag, $asset_url)
332{
333 $srcs = [];
334 if ($asset_url !== '') {
335 $srcs[] = ['type' => 'asset', 'label' => 'Release 附件', 'url' => $asset_url];
336 }
337 // archive/refs/tags 会由 GitHub 跳到 codeload,legacy.zip 是 codeload 的直链形式,两种都留作兜底
338 $srcs[] = [
339 'type' => 'source',
340 'label' => 'GitHub 源码包',
341 'url' => 'https://github.com/' . $cfg['repo'] . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
342 ];
343 $srcs[] = [
344 'type' => 'source',
345 'label' => 'GitHub 源码包(直连 codeload)',
346 'url' => 'https://codeload.github.com/' . $cfg['repo'] . '/legacy.zip/refs/tags/' . rawurlencode($tag),
347 ];
348
349 $out = [];
350 foreach ($srcs as $s) {
351 $out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $s['url'], 'via' => 'github', 'mirror' => ''];
352 foreach ($cfg['mirrors'] as $m) {
353 $out[] = ['type' => $s['type'], 'label' => $s['label'], 'url' => $m . $s['url'], 'via' => 'mirror', 'mirror' => $m];
354 }
355 }
356 return $out;
357}
358
359/** 检查结果缓存文件(后台首页每次加载都要问一遍,不限流会很快打满匿名请求配额) */
360function mnbt_updater_cache_file()
361{
362 $dir = mnbt_updater_root() . 'runtime/cache';
363 if (!is_dir($dir)) mnbt_updater_mkdir($dir);
364 return $dir . '/github_updater_check.json';
365}
366
367/** 检查最新版本;返回展示与下载所需的全部信息 */
368function mnbt_updater_check($force = false, $ttl = 1800)
369{
370 $cfg = mnbt_updater_config();
371 $cur = (int)($GLOBALS['WEBQB'] ?? 0);
372 $out = [
373 'ok' => 0, 'error' => '', 'repo' => $cfg['repo'], 'mirrors' => $cfg['mirrors'],
374 'has_token' => ($cfg['github_token'] !== ''), 'current' => 'V' . sprintf('%.2f', $cur / 1000),
375 'current_version' => $cur, 'tag' => '', 'latest' => '', 'version' => 0, 'name' => '', 'body' => '',
376 'asset_url' => '', 'asset_name' => '', 'asset_size' => 0, 'source' => '', 'source_label' => '',
377 'published_at' => '', 'fallback' => 0, 'can_update' => 0, 'candidates' => [],
378 ];
379 if ($cfg['repo'] === '') {
380 $out['error'] = '未配置更新仓库';
381 return $out;
382 }
383
384 // 仓库或本地版本变了要重新问;失败结果最多缓存 5 分钟
385 $cf = mnbt_updater_cache_file();
386 if (!$force && is_file($cf)) {
387 $c = json_decode((string)@file_get_contents($cf), true);
388 if (is_array($c) && isset($c['ts'], $c['repo'], $c['ver'], $c['data'])
389 && $c['repo'] === $cfg['repo'] && (int)$c['ver'] === $cur) {
390 $life = !empty($c['data']['ok']) ? min((int)($c['ttl'] ?? 1800), (int)$ttl) : min(300, (int)$ttl);
391 if (time() - (int)$c['ts'] < $life) {
392 return $c['data'];
393 }
394 }
395 }
396
397 $rel = null;
398 $err1 = '';
399 $api = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases/latest', $cfg);
400 if ($api['ok']) {
401 $js = $api['json'];
402 // latest 接口对非数组返回也会给 200,这里靠 tag_name 判定
403 if (isset($js['tag_name']) && mnbt_updater_tag_to_version($js['tag_name']) > 0) {
404 $rel = $js;
405 }
406 } else {
407 $err1 = $api['error'];
408 }
409 if (!is_array($rel)) {
410 // latest 不可用(未来 tag 可能不再以 V 开头,或接口限流),退化成列表取版本号最高的一个
411 $list = mnbt_updater_api_get('/repos/' . $cfg['repo'] . '/releases?per_page=20', $cfg);
412 if (!$list['ok']) {
413 $out['error'] = $list['error'] !== '' ? $list['error'] : $err1;
414 mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
415 return $out;
416 }
417 $best = null;
418 $bestv = 0;
419 foreach ($list['json'] as $js) {
420 if (!is_array($js) || empty($js['tag_name'])) continue;
421 if (!empty($js['draft']) || !empty($js['prerelease'])) continue;
422 $v = mnbt_updater_tag_to_version($js['tag_name']);
423 if ($v > $bestv) { $bestv = $v; $best = $js; }
424 }
425 if (!is_array($best)) {
426 $out['error'] = $err1 !== '' ? $err1 : 'GitHub 未找到可用的版本发布';
427 mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
428 return $out;
429 }
430 $rel = $best;
431 $out['fallback'] = 1;
432 }
433
434 $tag = (string)$rel['tag_name'];
435 $asset = mnbt_updater_pick_asset($rel['assets'] ?? []);
436 $ver = mnbt_updater_tag_to_version($tag);
437
438 $out['ok'] = 1;
439 $out['tag'] = $tag;
440 $out['latest'] = mnbt_updater_tag_display($tag);
441 $out['version'] = $ver;
442 $out['name'] = (string)($rel['name'] ?? '');
443 $out['body'] = (string)($rel['body'] ?? '');
444 $out['published_at'] = (string)($rel['published_at'] ?? '');
445 $out['asset_url'] = $asset['url'];
446 $out['asset_name'] = $asset['name'];
447 $out['asset_size'] = $asset['size'];
448 $out['can_update'] = ($ver > $cur) ? 1 : 0;
449 $out['source'] = $asset['url'] !== '' ? 'asset' : 'source';
450 $out['source_label'] = $asset['url'] !== '' ? 'Release 附件 ' . $asset['name'] : 'GitHub 自动源码包(需剥顶层目录)';
451 $out['candidates'] = mnbt_updater_candidates($cfg, $tag, $asset['url']);
452 mnbt_updater_cache_put($cfg, $cur, $out, $ttl);
453 return $out;
454}
455
456/** 写检查结果缓存;缓存里不含 Token,只存展示与下载所需数据 */
457function mnbt_updater_cache_put($cfg, $cur, $out, $ttl = 1800)
458{
459 $cf = mnbt_updater_cache_file();
460 @file_put_contents($cf, json_encode([
461 'ts' => time(),
462 'ttl' => (int)$ttl,
463 'repo' => $cfg['repo'],
464 'ver' => (int)$cur,
465 'data' => $out,
466 ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
467}
468
469/**
470 * 流式下载压缩包到临时文件:逐跳校验地址、校验 zip 魔数与字节数,失败即删除
471 * @return bool
472 */
473function mnbt_updater_download($url, $dest, $cfg, &$err)
474{
475 $err = '';
476 if (!function_exists('curl_init')) {
477 $err = '服务器未启用 curl 扩展';
478 return false;
479 }
480 if (!mnbt_updater_url_safe($url, $cfg, $err)) return false;
481 $fp = @fopen($dest, 'wb');
482 if (!$fp) {
483 $err = '无法创建临时文件 ' . $dest;
484 return false;
485 }
486 $cur = (string)$url;
487 $ok = false;
488 $heads = [];
489 for ($hop = 0; $hop < 6; $hop++) {
490 if (!mnbt_updater_url_safe($cur, $cfg, $err)) break;
491 $heads = [];
492 $ch = curl_init($cur);
493 curl_setopt($ch, CURLOPT_FILE, $fp);
494 curl_setopt($ch, CURLOPT_HEADER, false);
495 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
496 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
497 curl_setopt($ch, CURLOPT_TIMEOUT, 300);
498 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
499 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
500 curl_setopt($ch, CURLOPT_HTTPHEADER, [
501 // 下载请求不带 Token,避免跳转或镜像把它带出去
502 'User-Agent: MNBT-Updater/1.85',
503 'Accept: application/octet-stream',
504 ]);
505 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$heads) {
506 $len = strlen($line);
507 $p = strpos($line, ':');
508 if ($p !== false) {
509 $heads[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1));
510 }
511 return $len;
512 });
513 $res = curl_exec($ch);
514 $cerr = curl_error($ch);
515 $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
516 curl_close($ch);
517 if ($res === false) {
518 $err = $cerr !== '' ? $cerr : '下载中断';
519 break;
520 }
521 if ($code >= 300 && $code < 400) {
522 $loc = $heads['location'] ?? '';
523 if ($loc === '') {
524 $err = 'HTTP ' . $code . ' 但未返回跳转地址';
525 break;
526 }
527 $next = mnbt_updater_abs_url($cur, $loc);
528 if ($next === '') {
529 $err = '跳转地址无效';
530 break;
531 }
532 $cur = $next;
533 // 跳转响应可能自带一小段 HTML,会把压缩包开头弄脏,跳一次就把文件截回空
534 rewind($fp);
535 ftruncate($fp, 0);
536 continue;
537 }
538 if ($code !== 200) {
539 $err = 'HTTP ' . $code;
540 break;
541 }
542 $ok = true;
543 break;
544 }
545 fflush($fp);
546 $bytes = ftell($fp);
547 fclose($fp);
548
549 if ($ok) {
550 $expect = (int)($heads['content-length'] ?? 0);
551 if ($expect > 0 && $expect !== $bytes) {
552 $err = '文件不完整,应为 ' . $expect . ' 字节,实际 ' . $bytes . ' 字节';
553 $ok = false;
554 } elseif ($bytes < 1024) {
555 $err = '下载内容过小(' . $bytes . ' 字节),不像有效的压缩包';
556 $ok = false;
557 } elseif (!mnbt_updater_is_zip($dest)) {
558 $err = '下载内容不是有效的 zip 压缩包(可能是镜像返回的错误页)';
559 $ok = false;
560 }
561 }
562 if (!$ok) {
563 @unlink($dest);
564 }
565 return $ok;
566}
567
568/** 相对跳转地址转绝对 */
569function mnbt_updater_abs_url($base, $loc)
570{
571 $loc = trim((string)$loc);
572 if ($loc === '') return '';
573 if (preg_match('#^https?://#i', $loc)) return $loc;
574 $p = parse_url($base);
575 if (!$p || empty($p['host'])) return '';
576 $scheme = isset($p['scheme']) ? $p['scheme'] : 'https';
577 if (strpos($loc, '/') === 0) return $scheme . '://' . $p['host'] . $loc;
578 $dir = isset($p['path']) ? substr($p['path'], 0, strrpos($p['path'], '/') + 1) : '/';
579 return $scheme . '://' . $p['host'] . $dir . $loc;
580}
581
582/** zip 魔数校验 */
583function mnbt_updater_is_zip($file)
584{
585 if (!is_file($file)) return false;
586 $fp = @fopen($file, 'rb');
587 if (!$fp) return false;
588 $magic = fread($fp, 4);
589 fclose($fp);
590 return $magic === "PK\x03\x04" || $magic === "PK\x05\x06";
591}
592
593/** zip 条目名安全校验:禁止绝对路径、盘符、反斜杠与 .. 段 */
594function mnbt_updater_entry_safe($name)
595{
596 $name = (string)$name;
597 if ($name === '' || strpos($name, "\0") !== false) return false;
598 if (strpos($name, '/') === 0) return false;
599 if (strpos($name, '\\') !== false) return false;
600 if (preg_match('#^[A-Za-z]:#', $name)) return false;
601 foreach (explode('/', $name) as $seg) {
602 if ($seg === '..') return false;
603 }
604 return true;
605}
606
607/**
608 * 解压前扫描:拒绝危险条目、识别需要剥掉的顶层目录、确认剥完之后确实是站点根
609 * @return array|null 成功返回 ['strip'=>..,'entries'=>..,'files'=>..],失败返回 null 并写 $err
610 */
611function mnbt_updater_zip_analyze($file, &$err)
612{
613 $err = '';
614 if (!class_exists('ZipArchive')) {
615 $err = '服务器未启用 Zip 扩展,无法在线更新';
616 return null;
617 }
618 $zip = new ZipArchive();
619 $op = $zip->open($file);
620 if ($op !== true) {
621 $err = '压缩包无法打开(错误码 ' . $op . '),可能下载不完整';
622 return null;
623 }
624 $n = (int)$zip->numFiles;
625 if ($n <= 0) {
626 $zip->close();
627 $err = '压缩包是空的';
628 return null;
629 }
630 $names = [];
631 $files = 0;
632 for ($i = 0; $i < $n; $i++) {
633 $e = $zip->statIndex($i);
634 $name = isset($e['name']) ? (string)$e['name'] : '';
635 if (!mnbt_updater_entry_safe($name)) {
636 $zip->close();
637 $err = '压缩包含非法路径,已中止:' . $name;
638 return null;
639 }
640 $names[] = $name;
641 if (substr($name, -1) !== '/') $files++;
642 }
643 $strip = mnbt_updater_zip_strip($names);
644 // 附件包(无顶层目录)与源码包(剥掉顶层目录后)都必须落在站点根上,否则视为包内容不对
645 if (!mnbt_updater_zip_looks_like_site($names, $strip)) {
646 $zip->close();
647 $err = '压缩包结构不符合站点根目录,已中止';
648 return null;
649 }
650 $zip->close();
651 return ['strip' => $strip, 'entries' => $n, 'files' => $files];
652}
653
654/**
655 * 顶层目录识别:GitHub 自动源码包的顶层名有两种(MNBT-1.84/ 与 1181469655-MNBT-<sha>/),
656 * 所以只按「所有条目共享同一个顶层目录」来判断,不硬编码名字
657 */
658function mnbt_updater_zip_strip($names)
659{
660 $top = null;
661 foreach ($names as $name) {
662 $pos = strpos($name, '/');
663 if ($pos === false || $pos === 0) return '';
664 $seg = substr($name, 0, $pos);
665 if ($top === null) {
666 $top = $seg;
667 } elseif ($top !== $seg) {
668 return '';
669 }
670 }
671 return $top === null ? '' : $top . '/';
672}
673
674/** 剥掉顶层目录后是否还是一个站点根 */
675function mnbt_updater_zip_looks_like_site($names, $strip)
676{
677 $need = ['MPHX/function.php', 'admin/ajax.php', 'index.php', 'cf_up.php'];
678 $len = strlen($strip);
679 foreach ($names as $name) {
680 if ($strip !== '' && strncmp($name, $strip, $len) !== 0) continue;
681 $rel = rtrim($strip === '' ? $name : substr($name, $len), '/');
682 if ($rel !== '' && in_array($rel, $need, true)) return true;
683 }
684 return false;
685}
686
687/** 逐级建目录 */
688function mnbt_updater_mkdir($dir)
689{
690 if (is_dir($dir)) return true;
691 return @mkdir($dir, 0755, true);
692}
693
694/**
695 * 逐条目解压(需要剥顶层目录时 extractTo 做不到,所以统一走这里)
696 * @return bool
697 */
698function mnbt_updater_extract($file, $root, $strip, &$err)
699{
700 $err = '';
701 $zip = new ZipArchive();
702 if ($zip->open($file) !== true) {
703 $err = '压缩包打开失败';
704 return false;
705 }
706 $n = (int)$zip->numFiles;
707 for ($i = 0; $i < $n; $i++) {
708 $e = $zip->statIndex($i);
709 $name = isset($e['name']) ? (string)$e['name'] : '';
710 if (!mnbt_updater_entry_safe($name)) {
711 $zip->close();
712 $err = '压缩包含非法路径,已中止:' . $name;
713 return false;
714 }
715 $rel = $name;
716 if ($strip !== '') {
717 if (strpos($name, $strip) !== 0) continue;
718 $rel = substr($name, strlen($strip));
719 }
720 if ($rel === '') continue;
721 $target = $root . $rel;
722 if (substr($name, -1) === '/') {
723 if (!is_dir($target) && !mnbt_updater_mkdir($target)) {
724 $zip->close();
725 $err = '目录创建失败:' . $rel;
726 return false;
727 }
728 continue;
729 }
730 $dir = dirname($target);
731 if (!is_dir($dir) && !mnbt_updater_mkdir($dir)) {
732 $zip->close();
733 $err = '目录创建失败:' . $dir;
734 return false;
735 }
736 $src = $zip->getStream($name);
737 if ($src === false) {
738 $zip->close();
739 $err = '条目读取失败:' . $rel;
740 return false;
741 }
742 $dst = @fopen($target, 'wb');
743 if (!$dst) {
744 fclose($src);
745 $zip->close();
746 $err = '文件写入失败:' . $rel;
747 return false;
748 }
749 stream_copy_to_stream($src, $dst);
750 fclose($dst);
751 fclose($src);
752 }
753 $zip->close();
754 return true;
755}
756
757/** 需要备份还原的本地文件:装完就有、包里有但不能被覆盖 */
758function mnbt_updater_local_files()
759{
760 return ['config.php', 'cf_up.php', 'MPHX/SQ.php', 'install/install.lock'];
761}
762
763/** 需要备份还原的本地目录:包里可能带着作者的数据,整目录换回来 */
764function mnbt_updater_local_dirs()
765{
766 return ['api/cookie'];
767}
768
769/** 递归删除目录(只用于更新流程自己产生的临时目录) */
770function mnbt_updater_rrmdir($dir)
771{
772 if (!is_dir($dir)) return;
773 $items = @scandir($dir);
774 if (!is_array($items)) return;
775 foreach ($items as $it) {
776 if ($it === '.' || $it === '..') continue;
777 $p = $dir . '/' . $it;
778 if (is_dir($p)) {
779 mnbt_updater_rrmdir($p);
780 } else {
781 @unlink($p);
782 }
783 }
784 @rmdir($dir);
785}
786
787/** 递归复制目录 */
788function mnbt_updater_copy_dir($src, $dst)
789{
790 if (!is_dir($src)) return;
791 if (!is_dir($dst) && !mnbt_updater_mkdir($dst)) return;
792 $items = @scandir($src);
793 if (!is_array($items)) return;
794 foreach ($items as $it) {
795 if ($it === '.' || $it === '..') continue;
796 $s = $src . '/' . $it;
797 $d = $dst . '/' . $it;
798 if (is_dir($s)) {
799 mnbt_updater_copy_dir($s, $d);
800 } else {
801 @copy($s, $d);
802 }
803 }
804}
805
806/**
807 * 备份本地文件(记录哪些原本存在,install.lock 之类要按原样决定留还是删)
808 * @return array ['dir'=>备份目录,'files'=>[相对路径=>是否已备份],'dirs'=>[...],'present'=>[...]]
809 */
810function mnbt_updater_backup_local($root, $bak_dir, &$err)
811{
812 $err = '';
813 $st = ['dir' => $bak_dir, 'files' => [], 'dirs' => [], 'present' => []];
814 if (!mnbt_updater_mkdir($bak_dir)) {
815 $err = '备份目录创建失败:' . $bak_dir;
816 return $st;
817 }
818 foreach (mnbt_updater_local_files() as $rel) {
819 $src = $root . $rel;
820 $st['present'][$rel] = is_file($src);
821 if (!is_file($src)) continue;
822 $dst = $bak_dir . '/' . $rel;
823 if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
824 $err = '备份目录创建失败:' . dirname($dst);
825 continue;
826 }
827 if (@copy($src, $dst)) {
828 $st['files'][$rel] = 1;
829 } else {
830 $err = '本地文件备份失败:' . $rel;
831 }
832 }
833 foreach (mnbt_updater_local_dirs() as $rel) {
834 $src = rtrim($root, '/') . '/' . $rel;
835 $st['present'][$rel] = is_dir($src);
836 if (!is_dir($src)) continue;
837 mnbt_updater_copy_dir($src, $bak_dir . '/' . $rel);
838 $st['dirs'][$rel] = 1;
839 }
840 return $st;
841}
842
843/**
844 * 还原本地文件;backup 为空数组时按「跳过备份」处理,只保证不会误判成未安装
845 * @return bool
846 */
847function mnbt_updater_restore_local($root, $backup, &$err)
848{
849 $err = '';
850 $dir = isset($backup['dir']) ? $backup['dir'] : '';
851 if ($dir === '' || !is_dir($dir)) return true;
852 foreach ($backup['files'] as $rel => $ok) {
853 if (empty($ok)) continue;
854 $bak = $dir . '/' . $rel;
855 if (is_file($bak)) {
856 $dst = $root . $rel;
857 if (!is_dir(dirname($dst)) && !mnbt_updater_mkdir(dirname($dst))) {
858 $err .= '无法还原目录:' . dirname($dst) . ';';
859 continue;
860 }
861 if (!@copy($bak, $dst)) $err .= '本地文件还原失败:' . $rel . ';';
862 }
863 }
864 // 新包带的安装锁不能让站点被判定为未安装,原本没有就必须删掉
865 $lock = $root . 'install/install.lock';
866 if (empty($backup['files']['install/install.lock']) && is_file($lock)) {
867 @unlink($lock);
868 }
869 foreach ($backup['dirs'] as $rel => $ok) {
870 if (empty($ok)) continue;
871 $target = rtrim($root, '/') . '/' . $rel;
872 mnbt_updater_rrmdir($target);
873 mnbt_updater_copy_dir($dir . '/' . $rel, $target);
874 }
875 // 站点原本没有这些目录时,包里带进来的同名内容一律清掉(例如作者打包残留的 cookie)
876 foreach ($backup['present'] as $rel => $existed) {
877 if (!empty($existed)) continue;
878 $p = rtrim($root, '/') . '/' . $rel;
879 if (is_dir($p)) {
880 mnbt_updater_rrmdir($p);
881 }
882 }
883 return $err === '';
884}
885
886/** 更新流程的兜底状态:任何中断路径(超时、致命错误、用户关页面)都要收拾残局 */
887function mnbt_updater_guard_start()
888{
889 $GLOBALS['mnbt_updater_guard'] = [
890 'running' => 1, 'root' => mnbt_updater_root(), 'zip' => '', 'bak' => '',
891 'admin_now' => '', 'admin_orig' => '', 'moved' => 0, 'backup' => null, 'restored' => 0,
892 ];
893 register_shutdown_function('mnbt_updater_guard_shutdown');
894}
895
896/** 更新兜底状态的某个键 */
897function mnbt_updater_guard($key, $val)
898{
899 if (!isset($GLOBALS['mnbt_updater_guard']) || !is_array($GLOBALS['mnbt_updater_guard'])) {
900 $GLOBALS['mnbt_updater_guard'] = [];
901 }
902 $GLOBALS['mnbt_updater_guard'][$key] = $val;
903}
904
905/** 流程正常收尾:关掉兜底,避免残局处理再动一次文件 */
906function mnbt_updater_guard_done()
907{
908 $GLOBALS['mnbt_updater_guard']['running'] = 0;
909}
910
911/**
912 * 回滚/收尾:还原本地配置 → 把改名后的后台目录还原 → 清临时包与备份
913 * 成功与失败都走这里,所以逻辑必须可重复执行
914 */
915function mnbt_updater_guard_rollback()
916{
917 $g = $GLOBALS['mnbt_updater_guard'] ?? null;
918 if (!is_array($g)) return;
919 $root = !empty($g['root']) ? $g['root'] : mnbt_updater_root();
920 if (!empty($g['backup']) && empty($g['restored'])) {
921 $re = '';
922 mnbt_updater_restore_local($root, $g['backup'], $re);
923 $GLOBALS['mnbt_updater_guard']['restored'] = 1;
924 }
925 if (!empty($g['moved']) && !empty($g['admin_orig']) && !empty($g['admin_now']) && $g['admin_now'] !== $g['admin_orig']) {
926 if (!file_exists($g['admin_orig'])) {
927 @rename($g['admin_now'], $g['admin_orig']);
928 } elseif (is_dir($g['admin_orig']) && !is_dir($g['admin_now'] . '/api')) {
929 // 新包里恰好带了同名目录:先删掉空的占位目录再改回来
930 @rmdir($g['admin_orig']);
931 if (!file_exists($g['admin_orig'])) @rename($g['admin_now'], $g['admin_orig']);
932 }
933 }
934 if (!empty($g['zip']) && is_file($g['zip'])) @unlink($g['zip']);
935 if (!empty($g['bak']) && is_dir($g['bak'])) mnbt_updater_rrmdir($g['bak']);
936}
937
938/** 关机兜底:流程没正常跑完时至少把站点恢复成可访问状态 */
939function mnbt_updater_guard_shutdown()
940{
941 $g = $GLOBALS['mnbt_updater_guard'] ?? null;
942 if (!is_array($g) || empty($g['running'])) return;
943 mnbt_updater_guard_rollback();
944}
945
946/**
947 * V1.85 迁移链(MPHX/migrations.php / MN_dbver 游标表)的旧式兼容路径:
948 * 仅用于执行发布包里遗留的单文件 update/update.sql,按分号逐句跑,不做 DELIMITER 切分。
949 * 新版本请改放 update/update_v<3位或4位>_<slug>.sql,走 mnbt_migrations_run。
950 * 注意参数顺序是 mysqli(host, user, pwd, dbname[, port]),旧代码把 dbname 和 pwd 传反了,
951 * 导致连接总是失败,这里按正确顺序传
952 * @return string 空串表示成功,否则为错误说明
953 */
954function mnbt_updater_run_sql($file, $dbconfig)
955{
956 if (!is_file($file)) return '';
957 $_sql = @file_get_contents($file);
958 if ($_sql === false || trim($_sql) === '') return '';
959 $port = !empty($dbconfig['port']) ? (int)$dbconfig['port'] : 3306;
960 try {
961 $_mysqli = new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['pwd'], $dbconfig['dbname'], $port);
962 } catch (Throwable $e) {
963 return '连接数据库出错:' . $e->getMessage();
964 }
965 if (mysqli_connect_errno()) {
966 return '连接数据库出错:' . mysqli_connect_error();
967 }
968 $_mysqli->query('set names utf8;');
969 foreach (explode(';', $_sql) as $_value) {
970 if (trim((string)$_value) === '') continue;
971 $_mysqli->query($_value);
972 }
973 $_mysqli->close();
974 return '';
975}