仰望星辰工作室

better-staridc-MNBT

better-staridc-MNBT/ MPHX/plugin.php 48.3 KB · 1,576 行 原始文件
Z zfhsh first commit 2 天前
1<?php
2/**
3 * MNBT PHP 插件引擎(P0 + P1)
4 * 目录:app_plugins/{slug}/plugin.json + bootstrap.php
5 */
6if (!defined('IN_CRONLITE')) {
7 exit;
8}
9
10define('MNBT_PLUGIN_ROOT', ROOT . 'app_plugins/');
11
12$GLOBALS['mnbt_plugin_actions'] = [];
13$GLOBALS['mnbt_plugin_filters'] = [];
14$GLOBALS['mnbt_plugin_ajax'] = ['user' => [], 'admin' => []];
15$GLOBALS['mnbt_plugin_pages'] = ['user' => [], 'admin' => []];
16$GLOBALS['mnbt_plugin_menus'] = ['user' => [], 'admin' => []];
17$GLOBALS['mnbt_plugin_widgets'] = ['user' => [], 'admin' => []];
18$GLOBALS['mnbt_plugin_settings_tabs'] = [];
19$GLOBALS['mnbt_plugin_meta'] = [];
20$GLOBALS['mnbt_plugin_current'] = null;
21$GLOBALS['mnbt_plugin_booted'] = false;
22// 首页接管与通用路由(V1.81 P2)
23$GLOBALS['mnbt_plugin_home_handlers'] = [];
24$GLOBALS['mnbt_plugin_routes'] = [];
25// 支付插件注册表(V1.81 P3)
26$GLOBALS['mnbt_plugin_payments'] = [];
27
28function mnbt_plugin_ensure_tables()
29{
30 global $DB;
31 static $done = false;
32 if ($done || !isset($DB) || !is_object($DB)) {
33 return;
34 }
35 $done = true;
36 @$DB->query("CREATE TABLE IF NOT EXISTS `MN_plugin` (
37 `id` int(11) NOT NULL AUTO_INCREMENT,
38 `slug` varchar(64) NOT NULL,
39 `name` varchar(120) NOT NULL DEFAULT '',
40 `version` varchar(32) NOT NULL DEFAULT '',
41 `enabled` varchar(10) NOT NULL DEFAULT 'false',
42 `config_json` mediumtext,
43 `installed_at` varchar(50) NOT NULL DEFAULT '',
44 `updated_at` varchar(50) NOT NULL DEFAULT '',
45 PRIMARY KEY (`id`),
46 UNIQUE KEY `uk_slug` (`slug`)
47 ) ENGINE=MyISAM DEFAULT CHARSET=utf8");
48 @$DB->query("CREATE TABLE IF NOT EXISTS `MN_plugin_option` (
49 `id` int(11) NOT NULL AUTO_INCREMENT,
50 `plugin_slug` varchar(64) NOT NULL,
51 `k` varchar(120) NOT NULL,
52 `v` mediumtext,
53 PRIMARY KEY (`id`),
54 UNIQUE KEY `uk_plugin_k` (`plugin_slug`,`k`)
55 ) ENGINE=MyISAM DEFAULT CHARSET=utf8");
56}
57
58function mnbt_plugin_slug_valid($slug)
59{
60 return is_string($slug) && preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/', $slug);
61}
62
63function mnbt_plugin_path($slug = null)
64{
65 if ($slug === null) {
66 $slug = $GLOBALS['mnbt_plugin_current'];
67 }
68 if (!mnbt_plugin_slug_valid($slug)) {
69 return null;
70 }
71 return MNBT_PLUGIN_ROOT . $slug . '/';
72}
73
74function mnbt_plugin_url($slug = null, $rel = '')
75{
76 if ($slug === null) {
77 $slug = $GLOBALS['mnbt_plugin_current'];
78 }
79 if (!mnbt_plugin_slug_valid($slug)) {
80 return '';
81 }
82 $base = '/app_plugins/' . rawurlencode($slug) . '/';
83 $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
84 if ($rel === '' || strpos($rel, '..') !== false) {
85 return $base;
86 }
87 return $base . $rel;
88}
89
90function mnbt_plugin_read_json($slug)
91{
92 $dir = mnbt_plugin_path($slug);
93 if ($dir === null || !is_file($dir . 'plugin.json')) {
94 return null;
95 }
96 $raw = @file_get_contents($dir . 'plugin.json');
97 if ($raw === false || $raw === '') {
98 return null;
99 }
100 $data = json_decode($raw, true);
101 if (!is_array($data)) {
102 return null;
103 }
104 $id = isset($data['id']) ? (string)$data['id'] : $slug;
105 if ($id !== $slug) {
106 $data['id'] = $slug;
107 }
108 $data['id'] = $slug;
109 $data['name'] = isset($data['name']) ? (string)$data['name'] : $slug;
110 $data['version'] = isset($data['version']) ? (string)$data['version'] : '0.0.0';
111 $data['description'] = isset($data['description']) ? (string)$data['description'] : '';
112 $data['author'] = isset($data['author']) ? (string)$data['author'] : '';
113 // requires_plugins:声明本插件依赖的其他插件 slug 列表(必须在数据库存在且启用)
114 if (!isset($data['requires_plugins']) || !is_array($data['requires_plugins'])) {
115 $data['requires_plugins'] = [];
116 } else {
117 $clean = [];
118 foreach ($data['requires_plugins'] as $dep) {
119 $dep = is_string($dep) ? trim($dep) : '';
120 if ($dep !== '' && mnbt_plugin_slug_valid($dep) && !in_array($dep, $clean, true)) {
121 $clean[] = $dep;
122 }
123 }
124 $data['requires_plugins'] = $clean;
125 }
126 return $data;
127}
128
129/**
130 * 检查插件依赖是否满足。
131 *
132 * @param string $slug 被检查的插件 slug
133 * @param array $meta 可选,插件的 meta 数据(含 requires_plugins)
134 * @return array ['ok'=>bool, 'missing'=>string[] 未启用的依赖 slug 列表]
135 */
136function mnbt_plugin_check_dependencies($slug, $meta = null)
137{
138 if ($meta === null) {
139 $meta = mnbt_plugin_read_json($slug) ?: [];
140 }
141 $requires = isset($meta['requires_plugins']) && is_array($meta['requires_plugins']) ? $meta['requires_plugins'] : [];
142 $missing = [];
143 foreach ($requires as $dep) {
144 if (!mnbt_plugin_enabled($dep)) {
145 $missing[] = $dep;
146 }
147 }
148 return ['ok' => empty($missing), 'missing' => $missing];
149}
150
151function mnbt_plugin_scan_disk()
152{
153 $list = [];
154 if (!is_dir(MNBT_PLUGIN_ROOT)) {
155 return $list;
156 }
157 $dirs = @scandir(MNBT_PLUGIN_ROOT) ?: [];
158 foreach ($dirs as $dir) {
159 if ($dir === '.' || $dir === '..' || !mnbt_plugin_slug_valid($dir)) {
160 continue;
161 }
162 $base = MNBT_PLUGIN_ROOT . $dir;
163 if (!is_dir($base) || !is_file($base . '/plugin.json') || !is_file($base . '/bootstrap.php')) {
164 continue;
165 }
166 $meta = mnbt_plugin_read_json($dir);
167 if ($meta) {
168 $list[$dir] = $meta;
169 }
170 }
171 return $list;
172}
173
174function mnbt_plugin_db_row($slug)
175{
176 global $DB;
177 if (!mnbt_plugin_slug_valid($slug)) {
178 return null;
179 }
180 mnbt_plugin_ensure_tables();
181 return $DB->get_row_prepare("SELECT * FROM MN_plugin WHERE slug=? LIMIT 1", [$slug]) ?: null;
182}
183
184function mnbt_plugin_enabled($slug)
185{
186 $row = mnbt_plugin_db_row($slug);
187 return $row && (($row['enabled'] ?? '') === 'true' || ($row['enabled'] ?? '') === '1');
188}
189
190function mnbt_plugin_list()
191{
192 global $DB;
193 mnbt_plugin_ensure_tables();
194 $disk = mnbt_plugin_scan_disk();
195 $dbRows = $DB->get_all_prepare("SELECT * FROM MN_plugin WHERE 1") ?: [];
196 $dbMap = [];
197 foreach ($dbRows as $r) {
198 $dbMap[$r['slug']] = $r;
199 }
200 $out = [];
201 foreach ($disk as $slug => $meta) {
202 $row = $dbMap[$slug] ?? null;
203 $out[] = [
204 'slug' => $slug,
205 'name' => $meta['name'],
206 'version' => $meta['version'],
207 'description' => $meta['description'],
208 'author' => $meta['author'],
209 'meta' => $meta,
210 'installed' => $row ? true : false,
211 'enabled' => $row && (($row['enabled'] ?? '') === 'true' || ($row['enabled'] ?? '') === '1'),
212 'db_version' => $row['version'] ?? '',
213 'updated_at' => $row['updated_at'] ?? '',
214 ];
215 }
216 usort($out, function ($a, $b) {
217 return strcmp($a['slug'], $b['slug']);
218 });
219 return $out;
220}
221
222function mnbt_plugin_run_sql_file($file)
223{
224 global $DB;
225 if (!is_file($file)) {
226 return true;
227 }
228 $sql = @file_get_contents($file);
229 if ($sql === false || trim($sql) === '') {
230 return true;
231 }
232 $parts = preg_split('/;\s*[\r\n]+/', $sql);
233 foreach ($parts as $stmt) {
234 // 逐行剔除 `--` 注释行,避免「注释 + SQL」同段被整体误判为注释而跳过建表/删表语句
235 $lines = [];
236 foreach (explode("\n", $stmt) as $line) {
237 $trimmed = ltrim($line);
238 if ($trimmed === '' || strpos($trimmed, '--') === 0) {
239 continue;
240 }
241 $lines[] = $line;
242 }
243 $stmt = trim(implode("\n", $lines));
244 if ($stmt === '') {
245 continue;
246 }
247 @$DB->query($stmt);
248 }
249 return true;
250}
251
252function mnbt_plugin_install($slug)
253{
254 global $DB, $date;
255 if (!mnbt_plugin_slug_valid($slug)) {
256 return '插件标识无效';
257 }
258 $meta = mnbt_plugin_read_json($slug);
259 if (!$meta) {
260 return '未找到 plugin.json';
261 }
262 if (!is_file(mnbt_plugin_path($slug) . 'bootstrap.php')) {
263 return '缺少 bootstrap.php';
264 }
265 // 依赖检查:安装时要求所有 requires_plugins 已安装(不要求启用,启用时再检查)
266 $requires = isset($meta['requires_plugins']) && is_array($meta['requires_plugins']) ? $meta['requires_plugins'] : [];
267 foreach ($requires as $dep) {
268 if (!mnbt_plugin_db_row($dep)) {
269 return '本插件依赖的插件尚未安装:' . $dep;
270 }
271 }
272 mnbt_plugin_ensure_tables();
273 $dir = mnbt_plugin_path($slug);
274 mnbt_plugin_run_sql_file($dir . 'install.sql');
275 $row = mnbt_plugin_db_row($slug);
276 $now = isset($date) ? $date : date('Y-m-d H:i:s');
277 if ($row) {
278 $DB->query_prepare(
279 "UPDATE MN_plugin SET name=?, version=?, updated_at=? WHERE slug=?",
280 [$meta['name'], $meta['version'], $now, $slug]
281 );
282 } else {
283 $DB->query_prepare(
284 "INSERT INTO MN_plugin (slug, name, version, enabled, config_json, installed_at, updated_at) VALUES (?,?,?,?,?,?,?)",
285 [$slug, $meta['name'], $meta['version'], 'false', '', $now, $now]
286 );
287 }
288 return true;
289}
290
291function mnbt_plugin_set_enabled($slug, $enabled)
292{
293 global $DB, $date;
294 if (!mnbt_plugin_slug_valid($slug)) {
295 return '插件标识无效';
296 }
297 $meta = mnbt_plugin_read_json($slug);
298 if (!$meta) {
299 return '插件不存在';
300 }
301 mnbt_plugin_ensure_tables();
302 $row = mnbt_plugin_db_row($slug);
303 if (!$row) {
304 $r = mnbt_plugin_install($slug);
305 if ($r !== true) {
306 return $r;
307 }
308 $row = mnbt_plugin_db_row($slug);
309 }
310 // 启用时检查依赖:所有 requires_plugins 必须已启用
311 if ($enabled) {
312 $dep = mnbt_plugin_check_dependencies($slug, $meta);
313 if (!$dep['ok']) {
314 return '本插件依赖的插件未启用:' . implode(', ', $dep['missing']);
315 }
316 }
317 $flag = $enabled ? 'true' : 'false';
318 $now = isset($date) ? $date : date('Y-m-d H:i:s');
319 $DB->query_prepare(
320 "UPDATE MN_plugin SET enabled=?, name=?, version=?, updated_at=? WHERE slug=?",
321 [$flag, $meta['name'], $meta['version'], $now, $slug]
322 );
323 return true;
324}
325
326function mnbt_plugin_uninstall($slug)
327{
328 global $DB;
329 if (!mnbt_plugin_slug_valid($slug)) {
330 return '插件标识无效';
331 }
332 $dir = mnbt_plugin_path($slug);
333 if ($dir) {
334 mnbt_plugin_run_sql_file($dir . 'uninstall.sql');
335 }
336 mnbt_plugin_ensure_tables();
337 $DB->query_prepare("DELETE FROM MN_plugin_option WHERE plugin_slug=?", [$slug]);
338 $DB->query_prepare("DELETE FROM MN_plugin WHERE slug=?", [$slug]);
339 return true;
340}
341
342function mnbt_add_action($hook, $callback, $priority = 10)
343{
344 $hook = (string)$hook;
345 $priority = (int)$priority;
346 if (!isset($GLOBALS['mnbt_plugin_actions'][$hook])) {
347 $GLOBALS['mnbt_plugin_actions'][$hook] = [];
348 }
349 if (!isset($GLOBALS['mnbt_plugin_actions'][$hook][$priority])) {
350 $GLOBALS['mnbt_plugin_actions'][$hook][$priority] = [];
351 }
352 $GLOBALS['mnbt_plugin_actions'][$hook][$priority][] = [
353 'cb' => $callback,
354 'plugin' => $GLOBALS['mnbt_plugin_current'],
355 ];
356}
357
358function mnbt_add_filter($hook, $callback, $priority = 10)
359{
360 $hook = (string)$hook;
361 $priority = (int)$priority;
362 if (!isset($GLOBALS['mnbt_plugin_filters'][$hook])) {
363 $GLOBALS['mnbt_plugin_filters'][$hook] = [];
364 }
365 if (!isset($GLOBALS['mnbt_plugin_filters'][$hook][$priority])) {
366 $GLOBALS['mnbt_plugin_filters'][$hook][$priority] = [];
367 }
368 $GLOBALS['mnbt_plugin_filters'][$hook][$priority][] = [
369 'cb' => $callback,
370 'plugin' => $GLOBALS['mnbt_plugin_current'],
371 ];
372}
373
374function mnbt_do_action($hook)
375{
376 $args = func_get_args();
377 array_shift($args);
378 $hook = (string)$hook;
379 if (empty($GLOBALS['mnbt_plugin_actions'][$hook])) {
380 return;
381 }
382 $buckets = $GLOBALS['mnbt_plugin_actions'][$hook];
383 ksort($buckets, SORT_NUMERIC);
384 foreach ($buckets as $list) {
385 foreach ($list as $item) {
386 $prev = $GLOBALS['mnbt_plugin_current'];
387 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
388 try {
389 call_user_func_array($item['cb'], $args);
390 } catch (Throwable $e) {
391 error_log('[MNBT plugin] action ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage());
392 }
393 $GLOBALS['mnbt_plugin_current'] = $prev;
394 }
395 }
396}
397
398function mnbt_apply_filters($hook, $value)
399{
400 $args = func_get_args();
401 array_shift($args);
402 $hook = (string)$hook;
403 if (empty($GLOBALS['mnbt_plugin_filters'][$hook])) {
404 return $value;
405 }
406 $buckets = $GLOBALS['mnbt_plugin_filters'][$hook];
407 ksort($buckets, SORT_NUMERIC);
408 foreach ($buckets as $list) {
409 foreach ($list as $item) {
410 $prev = $GLOBALS['mnbt_plugin_current'];
411 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
412 try {
413 $args[0] = $value;
414 $value = call_user_func_array($item['cb'], $args);
415 } catch (Throwable $e) {
416 error_log('[MNBT plugin] filter ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage());
417 }
418 $GLOBALS['mnbt_plugin_current'] = $prev;
419 }
420 }
421 return $value;
422}
423
424function mnbt_register_ajax($side, $gn, $callback, $auth = null)
425{
426 $side = $side === 'admin' ? 'admin' : 'user';
427 $gn = (string)$gn;
428 if ($gn === '' || !is_callable($callback)) {
429 return false;
430 }
431 if (isset($GLOBALS['mnbt_plugin_ajax'][$side][$gn])) {
432 error_log('[MNBT plugin] ajax gn conflict: ' . $side . '/' . $gn);
433 return false;
434 }
435 $GLOBALS['mnbt_plugin_ajax'][$side][$gn] = [
436 'cb' => $callback,
437 'plugin' => $GLOBALS['mnbt_plugin_current'],
438 'auth' => $auth,
439 ];
440 return true;
441}
442
443function mnbt_register_page($side, $page, $file, $title = '', $perm = null)
444{
445 $side = $side === 'admin' ? 'admin' : 'user';
446 $page = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$page);
447 if ($page === '' || $file === '') {
448 return false;
449 }
450 $slug = $GLOBALS['mnbt_plugin_current'];
451 if (!mnbt_plugin_slug_valid($slug)) {
452 return false;
453 }
454 $GLOBALS['mnbt_plugin_pages'][$side][$slug . ':' . $page] = [
455 'plugin' => $slug,
456 'page' => $page,
457 'file' => $file,
458 'title' => $title,
459 'perm' => $perm,
460 ];
461 return true;
462}
463
464function mnbt_register_menu($side, $item)
465{
466 $side = $side === 'admin' ? 'admin' : 'user';
467 if (!is_array($item) || empty($item['title'])) {
468 return false;
469 }
470 $slug = $GLOBALS['mnbt_plugin_current'];
471 $item['plugin'] = $slug;
472 $item['order'] = isset($item['order']) ? (int)$item['order'] : 50;
473 if (empty($item['children'])) {
474 if (empty($item['url']) && !empty($item['page'])) {
475 $base = $side === 'admin' ? 'plugin.php' : 'plugin.php';
476 $item['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($item['page']);
477 }
478 } else {
479 foreach ($item['children'] as $k => $child) {
480 $item['children'][$k]['plugin'] = $slug;
481 $item['children'][$k]['order'] = isset($child['order']) ? (int)$child['order'] : 50;
482 if (empty($child['url']) && !empty($child['page'])) {
483 $base = $side === 'admin' ? 'plugin.php' : 'plugin.php';
484 $item['children'][$k]['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($child['page']);
485 }
486 if (!empty($child['children'])) {
487 foreach ($child['children'] as $ck => $gc) {
488 $item['children'][$k]['children'][$ck]['plugin'] = $slug;
489 $item['children'][$k]['children'][$ck]['order'] = isset($gc['order']) ? (int)$gc['order'] : 50;
490 if (empty($gc['url']) && !empty($gc['page'])) {
491 $base = $side === 'admin' ? 'plugin.php' : 'plugin.php';
492 $item['children'][$k]['children'][$ck]['url'] = $base . '?p=' . rawurlencode($slug) . '&page=' . rawurlencode($gc['page']);
493 }
494 }
495 }
496 }
497 }
498 $GLOBALS['mnbt_plugin_menus'][$side][] = $item;
499 return true;
500}
501
502/**
503 * 注册页面接管 —— 让插件接管或包裹主题整页渲染(mnbt_render 调用)
504 *
505 * 当 mnbt_render($view) 被调用时,引擎会按 priority 升序遍历所有注册的 override 回调,
506 * 第一个返回非 null 的值即生效,后续回调不再调用(短路语义)。
507 *
508 * @param string $scope 'user' 或 'admin'
509 * @param string $view 视图名(如 'set', 'list', 'sy', 'index')
510 * @param callable $callback 签名: function(array $vars): mixed
511 * 返回值三选一:
512 * - null: 不接管(默认行为)
513 * - string: 完全接管,输出该字符串
514 * - ['before'=>string,'after'=>string]: 包裹模式,原视图前后插入内容
515 * @param int $priority 优先级(数字越小越先执行),默认 10
516 * @return bool
517 *
518 * 示例 1:完全接管(替换整页)
519 * mnbt_register_page_override('user', 'set', function ($vars) {
520 * if (($_GET['gn'] ?? '') !== 'my_section') return null;
521 * return '<div>我的自定义内容</div>';
522 * });
523 *
524 * 示例 2:包裹模式(在原页面前后插入 banner)
525 * mnbt_register_page_override('user', 'index', function ($vars) {
526 * return [
527 * 'before' => '<div class="banner">公告</div>',
528 * 'after' => '<script>console.log("page loaded")</script>',
529 * ];
530 * });
531 *
532 * 示例 3:按请求参数决定是否接管
533 * mnbt_register_page_override('admin', 'list', function ($vars) {
534 * if (($_GET['gn'] ?? '') === 'plugin_section') {
535 * return render_my_plugin_page();
536 * }
537 * return null; // 其他 gn 走原逻辑
538 * }, 5);
539 */
540function mnbt_register_page_override($scope, $view, $callback, $priority = 10)
541{
542 if (!is_callable($callback)) {
543 return false;
544 }
545 $scope = ($scope === 'admin') ? 'admin' : 'user';
546 $view = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$view);
547 if ($view === '') {
548 return false;
549 }
550 return mnbt_add_filter('render.' . $scope . '.' . $view, $callback, $priority);
551}
552
553/**
554 * 注册 partial 接管 —— 让插件接管或包裹主题局部模板(mnbt_theme_include 调用)
555 *
556 * 当 mnbt_theme_include($view) 被调用时,引擎会按 priority 升序遍历所有注册的 override 回调,
557 * 第一个返回非 null 的值即生效,后续回调不再调用(短路语义)。
558 *
559 * @param string $scope 'user' 或 'admin'
560 * @param string $view partial 名(如 'head', 'footer', 'sidebar')
561 * @param callable $callback 签名: function(array $vars): mixed
562 * 返回值三选一:
563 * - null: 不接管(默认行为)
564 * - string: 完全接管,输出该字符串
565 * - ['before'=>string,'after'=>string]: 包裹模式,原 partial 前后插入内容
566 * @param int $priority 优先级(数字越小越先执行),默认 10
567 * @return bool
568 *
569 * 示例:在用户端 head 末尾追加自定义 CSS
570 * mnbt_register_partial_override('user', 'head', function ($vars) {
571 * return ['after' => '<style>.my-plugin-banner{color:red}</style>'];
572 * });
573 */
574function mnbt_register_partial_override($scope, $view, $callback, $priority = 10)
575{
576 if (!is_callable($callback)) {
577 return false;
578 }
579 $scope = ($scope === 'admin') ? 'admin' : 'user';
580 $view = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$view);
581 if ($view === '') {
582 return false;
583 }
584 return mnbt_add_filter('include.' . $scope . '.' . $view, $callback, $priority);
585}
586
587function mnbt_plugin_option_get($slug, $key, $default = null)
588{
589 global $DB;
590 if (!mnbt_plugin_slug_valid($slug) || $key === '') {
591 return $default;
592 }
593 mnbt_plugin_ensure_tables();
594 $row = $DB->get_row_prepare("SELECT v FROM MN_plugin_option WHERE plugin_slug=? AND k=? LIMIT 1", [$slug, (string)$key]);
595 if (!$row) {
596 return $default;
597 }
598 $v = $row['v'];
599 if (is_string($v) && $v !== '' && ($v[0] === '{' || $v[0] === '[' || $v[0] === '"')) {
600 $j = json_decode($v, true);
601 if (json_last_error() === JSON_ERROR_NONE) {
602 return $j;
603 }
604 }
605 return $v;
606}
607
608function mnbt_plugin_option_set($slug, $key, $value)
609{
610 global $DB;
611 if (!mnbt_plugin_slug_valid($slug) || $key === '') {
612 return false;
613 }
614 mnbt_plugin_ensure_tables();
615 if (is_array($value) || is_object($value)) {
616 $value = json_encode($value, JSON_UNESCAPED_UNICODE);
617 } else {
618 $value = (string)$value;
619 }
620 $exist = $DB->get_row_prepare("SELECT id FROM MN_plugin_option WHERE plugin_slug=? AND k=? LIMIT 1", [$slug, (string)$key]);
621 if ($exist) {
622 return (bool)$DB->query_prepare("UPDATE MN_plugin_option SET v=? WHERE plugin_slug=? AND k=?", [$value, $slug, (string)$key]);
623 }
624 return (bool)$DB->query_prepare("INSERT INTO MN_plugin_option (plugin_slug, k, v) VALUES (?,?,?)", [$slug, (string)$key, $value]);
625}
626
627function mnbt_plugin_option_all($slug)
628{
629 global $DB;
630 if (!mnbt_plugin_slug_valid($slug)) {
631 return [];
632 }
633 mnbt_plugin_ensure_tables();
634 $rows = $DB->get_all_prepare("SELECT k,v FROM MN_plugin_option WHERE plugin_slug=?", [$slug]) ?: [];
635 $out = [];
636 foreach ($rows as $r) {
637 $out[$r['k']] = mnbt_plugin_option_get($slug, $r['k'], $r['v']);
638 }
639 return $out;
640}
641
642function mnbt_plugin_require_admin()
643{
644 global $islogin;
645 if (!isset($islogin) || (int)$islogin !== 1) {
646 if (function_exists('json_exit')) {
647 json_exit('请登陆');
648 }
649 exit('{"code":"请登陆"}');
650 }
651}
652
653function mnbt_plugin_require_user()
654{
655 global $islogins;
656 if (!isset($islogins) || (int)$islogins !== 1) {
657 if (function_exists('json_exit')) {
658 json_exit('请登陆');
659 }
660 exit('{"code":"请登陆"}');
661 }
662}
663
664function mnbt_plugin_auth_check($auth)
665{
666 if ($auth === null || $auth === '' || $auth === 'none') {
667 return true;
668 }
669 if ($auth === 'admin') {
670 global $islogin;
671 if (!isset($islogin) || (int)$islogin !== 1) {
672 return false;
673 }
674 return true;
675 }
676 if ($auth === 'user') {
677 global $islogins;
678 if (!isset($islogins) || (int)$islogins !== 1) {
679 return false;
680 }
681 return true;
682 }
683 if (is_callable($auth)) {
684 return (bool)call_user_func($auth);
685 }
686 return false;
687}
688
689function mnbt_plugin_auth_fail($auth)
690{
691 if ($auth === 'admin') {
692 if (function_exists('json_exit')) {
693 json_exit('请登陆后台');
694 }
695 exit('{"code":"请登陆后台"}');
696 }
697 if ($auth === 'user') {
698 if (function_exists('json_exit')) {
699 json_exit('请登陆');
700 }
701 exit('{"code":"请登陆"}');
702 }
703 if (is_callable($auth)) {
704 $fnName = is_string($auth) ? $auth : gettype($auth);
705 $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
706 if (isset($caller[1]['function']) && $caller[1]['function'] === 'mnbt_plugin_dispatch_route') {
707 if (function_exists('user_info_url')) {
708 header('Location: ' . user_info_url('account/login'));
709 exit;
710 }
711 }
712 if (function_exists('json_exit')) {
713 json_exit('请登陆');
714 }
715 exit('{"code":"请登陆"}');
716 }
717 if (function_exists('json_exit')) {
718 json_exit('权限不足');
719 }
720 exit('{"code":"权限不足"}');
721}
722
723function mnbt_plugin_dispatch_ajax($side, $egn)
724{
725 $side = $side === 'admin' ? 'admin' : 'user';
726 $egn = (string)$egn;
727 if ($egn === '' || empty($GLOBALS['mnbt_plugin_ajax'][$side][$egn])) {
728 return false;
729 }
730 $item = $GLOBALS['mnbt_plugin_ajax'][$side][$egn];
731 if (!mnbt_plugin_auth_check($item['auth'] ?? null)) {
732 mnbt_plugin_auth_fail($item['auth'] ?? null);
733 }
734 mnbt_csrf_validate_request();
735 $prev = $GLOBALS['mnbt_plugin_current'];
736 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
737 try {
738 call_user_func($item['cb'], $egn, $side);
739 } catch (Throwable $e) {
740 error_log('[MNBT plugin] ajax ' . $side . '/' . $egn . ': ' . $e->getMessage());
741 if (function_exists('json_exit')) {
742 json_exit('插件接口异常');
743 }
744 exit('{"code":"插件接口异常"}');
745 }
746 $GLOBALS['mnbt_plugin_current'] = $prev;
747 return true;
748}
749
750function mnbt_plugin_menus($side)
751{
752 $side = $side === 'admin' ? 'admin' : 'user';
753 $items = $GLOBALS['mnbt_plugin_menus'][$side] ?? [];
754 $items = mnbt_apply_filters('menu.' . $side, $items);
755 usort($items, function ($a, $b) {
756 return ($a['order'] ?? 50) - ($b['order'] ?? 50);
757 });
758 return $items;
759}
760
761function _mnbt_plugin_render_menu_item($it, $depth = 0)
762{
763 $title = htmlspecialchars($it['title'] ?? '', ENT_QUOTES, 'UTF-8');
764 $iconHtml = '';
765 if (!empty($it['icon'])) {
766 $icon = htmlspecialchars($it['icon'], ENT_QUOTES, 'UTF-8');
767 $iconHtml = '<i class="mdi ' . $icon . '"></i> ';
768 }
769 if (!empty($it['children'])) {
770 $childrenHtml = _mnbt_plugin_render_menu_children($it['children'], $depth + 1);
771 $parentIconHtml = $iconHtml !== '' ? $iconHtml : '<i class="mdi mdi-puzzle"></i> ';
772 return '<li class="nav-item nav-item-has-subnav">'
773 . '<a href="javascript:void(0)">' . $parentIconHtml . '<span>' . $title . '</span></a>'
774 . '<ul class="nav nav-subnav">' . $childrenHtml . '</ul></li>';
775 }
776 $url = htmlspecialchars($it['url'] ?? 'javascript:void(0)', ENT_QUOTES, 'UTF-8');
777 $mt = !empty($it['multitabs']) || strpos($url, 'plugin.php') !== false ? ' multitabs' : '';
778 return '<li> <a class="' . trim($mt) . '" href="' . $url . '">' . $iconHtml . $title . '</a> </li>';
779}
780
781function _mnbt_plugin_render_menu_children($children, $depth = 1)
782{
783 usort($children, function ($a, $b) {
784 return ($a['order'] ?? 50) - ($b['order'] ?? 50);
785 });
786 $html = '';
787 foreach ($children as $child) {
788 $html .= _mnbt_plugin_render_menu_item($child, $depth);
789 }
790 return $html;
791}
792
793/**
794 * 内部:将插件菜单树渲染成 default 主题的侧边栏 HTML(lyear 风格)。
795 * 作为未注册主题渲染器时的 fallback。
796 */
797function _mnbt_plugin_render_default_menu_html($side)
798{
799 $side = $side === 'admin' ? 'admin' : 'user';
800 $items = mnbt_plugin_menus($side);
801 if (!$items) {
802 return '';
803 }
804 $groups = [];
805 $leafs = [];
806 foreach ($items as $it) {
807 if (!empty($it['children'])) {
808 $groups[] = $it;
809 } else {
810 $leafs[] = $it;
811 }
812 }
813 $html = '';
814 foreach ($groups as $group) {
815 $html .= _mnbt_plugin_render_menu_item($group, 1);
816 }
817 if (!empty($leafs)) {
818 $leafsHtml = _mnbt_plugin_render_menu_children($leafs, 1);
819 $html .= '<li class="nav-item nav-item-has-subnav">'
820 . '<a href="javascript:void(0)"><i class="mdi mdi-puzzle"></i> <span>插件管理</span></a>'
821 . '<ul class="nav nav-subnav">' . $leafsHtml . '</ul></li>';
822 }
823 return $html;
824}
825
826/**
827 * 渲染插件侧边栏菜单。
828 *
829 * 引擎优先使用当前主题注册的菜单渲染器(通过 mnbt_register_theme_menu_renderer)。
830 * 若当前主题没有注册渲染器,则回退到 default 主题结构(lyear 风格)。
831 *
832 * @param string $side 'user' 或 'admin'
833 * @return string
834 */
835function mnbt_plugin_render_menu_side_html($side)
836{
837 $side = $side === 'admin' ? 'admin' : 'user';
838 $renderer = $GLOBALS['mnbt_theme_menu_renderers'][$side] ?? null;
839 if (is_callable($renderer)) {
840 $items = mnbt_plugin_menus($side);
841 return (string)call_user_func($renderer, $items);
842 }
843 return _mnbt_plugin_render_default_menu_html($side);
844}
845
846function mnbt_plugin_render_menu_admin_html()
847{
848 return mnbt_plugin_render_menu_side_html('admin');
849}
850
851function mnbt_plugin_render_menu_user_html()
852{
853 return mnbt_plugin_render_menu_side_html('user');
854}
855
856/**
857 * 注册仪表盘小部件
858 * $item: title, html|callback, order, class
859 */
860function mnbt_register_widget($side, $item)
861{
862 $side = $side === 'admin' ? 'admin' : 'user';
863 if (!is_array($item) || (empty($item['title']) && empty($item['html']) && empty($item['callback']))) {
864 return false;
865 }
866 $item['plugin'] = $GLOBALS['mnbt_plugin_current'];
867 $item['order'] = isset($item['order']) ? (int)$item['order'] : 50;
868 $GLOBALS['mnbt_plugin_widgets'][$side][] = $item;
869 return true;
870}
871
872function mnbt_plugin_widgets($side)
873{
874 $side = $side === 'admin' ? 'admin' : 'user';
875 $items = $GLOBALS['mnbt_plugin_widgets'][$side] ?? [];
876 $items = mnbt_apply_filters('dashboard.' . $side . '.widgets', $items);
877 usort($items, function ($a, $b) {
878 return ($a['order'] ?? 50) - ($b['order'] ?? 50);
879 });
880 return $items;
881}
882
883function mnbt_plugin_render_widgets_html($side)
884{
885 $items = mnbt_plugin_widgets($side);
886 if (!$items) {
887 return '';
888 }
889 $html = '<div class="row mt-3">';
890 foreach ($items as $it) {
891 $prev = $GLOBALS['mnbt_plugin_current'];
892 $GLOBALS['mnbt_plugin_current'] = $it['plugin'] ?? null;
893 $body = '';
894 if (!empty($it['callback']) && is_callable($it['callback'])) {
895 ob_start();
896 try {
897 call_user_func($it['callback'], $side);
898 } catch (Throwable $e) {
899 echo '小部件错误';
900 error_log('[MNBT plugin] widget: ' . $e->getMessage());
901 }
902 $body = ob_get_clean();
903 } else {
904 $body = (string)($it['html'] ?? '');
905 }
906 $GLOBALS['mnbt_plugin_current'] = $prev;
907 $title = htmlspecialchars($it['title'] ?? '', ENT_QUOTES, 'UTF-8');
908 $col = htmlspecialchars($it['class'] ?? 'col-sm-6', ENT_QUOTES, 'UTF-8');
909 $html .= '<div class="' . $col . '"><div class="card"><div class="card-header"><h4>' . $title . '</h4></div><div class="card-body">' . $body . '</div></div></div>';
910 }
911 $html .= '</div>';
912 return $html;
913}
914
915/**
916 * 注册插件设置页签(出现在插件管理页或独立入口)
917 * $item: id, title, page|url, order
918 */
919function mnbt_register_settings_tab($item)
920{
921 if (!is_array($item) || empty($item['title'])) {
922 return false;
923 }
924 $slug = $GLOBALS['mnbt_plugin_current'];
925 $item['plugin'] = $slug;
926 $item['order'] = isset($item['order']) ? (int)$item['order'] : 50;
927 if (empty($item['url']) && !empty($item['page'])) {
928 $item['url'] = 'plugin.php?p=' . rawurlencode($slug) . '&page=' . rawurlencode($item['page']);
929 }
930 $GLOBALS['mnbt_plugin_settings_tabs'][] = $item;
931 return true;
932}
933
934function mnbt_plugin_settings_tabs()
935{
936 $items = $GLOBALS['mnbt_plugin_settings_tabs'] ?? [];
937 $items = mnbt_apply_filters('settings.admin.tabs', $items);
938 usort($items, function ($a, $b) {
939 return ($a['order'] ?? 50) - ($b['order'] ?? 50);
940 });
941 return $items;
942}
943
944/**
945 * 安全 HTTP 请求(仅 http/https)
946 * @return array{ok:bool,code:int,body:string,error:string,headers:array}
947 */
948function mnbt_http_request($method, $url, $body = null, $opts = [])
949{
950 $out = ['ok' => false, 'code' => 0, 'body' => '', 'error' => '', 'headers' => []];
951 $url = trim((string)$url);
952 if ($url === '' || !preg_match('#^https?://#i', $url)) {
953 $out['error'] = '仅允许 http/https URL';
954 return $out;
955 }
956 $parts = @parse_url($url);
957 if (!$parts || empty($parts['host'])) {
958 $out['error'] = 'URL 无效';
959 return $out;
960 }
961 $host = strtolower($parts['host']);
962 if ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1' || preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) {
963 if (empty($opts['allow_private'])) {
964 $out['error'] = '禁止访问内网地址';
965 return $out;
966 }
967 }
968 if (!function_exists('curl_init')) {
969 $out['error'] = 'curl 不可用';
970 return $out;
971 }
972 $method = strtoupper($method ?: 'GET');
973 $timeout = isset($opts['timeout']) ? max(1, (int)$opts['timeout']) : 15;
974 $headers = isset($opts['headers']) && is_array($opts['headers']) ? $opts['headers'] : [];
975 $ch = curl_init($url);
976 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
977 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
978 curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
979 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
980 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
981 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, !empty($opts['insecure']) ? false : true);
982 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, !empty($opts['insecure']) ? 0 : 2);
983 curl_setopt($ch, CURLOPT_USERAGENT, $opts['user_agent'] ?? 'MNBT-Plugin/1.83');
984 if ($method === 'POST') {
985 curl_setopt($ch, CURLOPT_POST, true);
986 if ($body !== null) {
987 if (is_array($body)) {
988 $body = json_encode($body, JSON_UNESCAPED_UNICODE);
989 $hasCt = false;
990 foreach ($headers as $h) {
991 if (stripos($h, 'Content-Type:') === 0) {
992 $hasCt = true;
993 break;
994 }
995 }
996 if (!$hasCt) {
997 $headers[] = 'Content-Type: application/json; charset=utf-8';
998 }
999 }
1000 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
1001 }
1002 } elseif ($method !== 'GET') {
1003 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
1004 if ($body !== null) {
1005 curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($body) ? json_encode($body, JSON_UNESCAPED_UNICODE) : $body);
1006 }
1007 }
1008 if ($headers) {
1009 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
1010 }
1011 $resp = curl_exec($ch);
1012 $err = curl_error($ch);
1013 $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
1014 curl_close($ch);
1015 if ($resp === false) {
1016 $out['error'] = $err ?: '请求失败';
1017 $out['code'] = $code;
1018 return $out;
1019 }
1020 $out['ok'] = $code >= 200 && $code < 300;
1021 $out['code'] = $code;
1022 $out['body'] = (string)$resp;
1023 if (!$out['ok'] && $out['error'] === '') {
1024 $out['error'] = 'HTTP ' . $code;
1025 }
1026 return $out;
1027}
1028
1029function mnbt_http_get($url, $opts = [])
1030{
1031 return mnbt_http_request('GET', $url, null, $opts);
1032}
1033
1034function mnbt_http_post($url, $body = null, $opts = [])
1035{
1036 return mnbt_http_request('POST', $url, $body, $opts);
1037}
1038
1039/** 当前插件 slug(bootstrap/钩子回调内有效) */
1040function mnbt_plugin_id()
1041{
1042 return $GLOBALS['mnbt_plugin_current'];
1043}
1044
1045function mnbt_plugin_find_page($side, $plugin, $page)
1046{
1047 $key = $plugin . ':' . $page;
1048 return $GLOBALS['mnbt_plugin_pages'][$side][$key] ?? null;
1049}
1050
1051function mnbt_plugin_render_page($side, $plugin, $page)
1052{
1053 mnbt_csrf_validate_request();
1054 $side = $side === 'admin' ? 'admin' : 'user';
1055 $info = mnbt_plugin_find_page($side, $plugin, $page);
1056 if (!$info) {
1057 http_response_code(404);
1058 echo '插件页面不存在';
1059 return false;
1060 }
1061 $file = $info['file'];
1062 if ($file[0] !== '/' && strpos($file, ':') === false) {
1063 $file = mnbt_plugin_path($plugin) . ltrim(str_replace('\\', '/', $file), '/');
1064 }
1065 $realPlugin = realpath(mnbt_plugin_path($plugin));
1066 $realFile = realpath($file);
1067 if ($realPlugin === false || $realFile === false || strpos($realFile, $realPlugin) !== 0 || !is_file($realFile)) {
1068 http_response_code(404);
1069 echo '插件页面文件无效';
1070 return false;
1071 }
1072 $prev = $GLOBALS['mnbt_plugin_current'];
1073 $GLOBALS['mnbt_plugin_current'] = $plugin;
1074 extract($GLOBALS, EXTR_SKIP);
1075 $title = $info['title'] ?: ($plugin . ' / ' . $page);
1076 $bufferLevel = ob_get_level();
1077 ob_start('mnbt_csrf_inject_html');
1078 try {
1079 include $realFile;
1080 } finally {
1081 while (ob_get_level() > $bufferLevel) ob_end_flush();
1082 $GLOBALS['mnbt_plugin_current'] = $prev;
1083 }
1084 return true;
1085}
1086
1087function mnbt_plugin_register($id, $meta = [])
1088{
1089 if (!mnbt_plugin_slug_valid($id)) {
1090 return false;
1091 }
1092 $GLOBALS['mnbt_plugin_meta'][$id] = is_array($meta) ? $meta : [];
1093 $GLOBALS['mnbt_plugin_meta'][$id]['id'] = $id;
1094 return true;
1095}
1096
1097function mnbt_plugins_boot()
1098{
1099 global $DB;
1100 if (!empty($GLOBALS['mnbt_plugin_booted'])) {
1101 return;
1102 }
1103 $GLOBALS['mnbt_plugin_booted'] = true;
1104 if (!is_dir(MNBT_PLUGIN_ROOT)) {
1105 @mkdir(MNBT_PLUGIN_ROOT, 0755, true);
1106 }
1107 mnbt_plugin_ensure_tables();
1108 $rows = @$DB->get_all_prepare("SELECT * FROM MN_plugin WHERE enabled=? OR enabled=?", ['true', '1']) ?: [];
1109 foreach ($rows as $row) {
1110 $slug = $row['slug'] ?? '';
1111 if (!mnbt_plugin_slug_valid($slug)) {
1112 continue;
1113 }
1114 $boot = mnbt_plugin_path($slug) . 'bootstrap.php';
1115 if (!is_file($boot)) {
1116 continue;
1117 }
1118 $meta = mnbt_plugin_read_json($slug);
1119 // 运行时依赖检查:依赖插件未启用则跳过 boot(防止调用未定义函数)
1120 $dep = mnbt_plugin_check_dependencies($slug, $meta);
1121 if (!$dep['ok']) {
1122 error_log('[MNBT plugin] ' . $slug . ' 依赖未满足,跳过 boot:' . implode(', ', $dep['missing']));
1123 continue;
1124 }
1125 $GLOBALS['mnbt_plugin_current'] = $slug;
1126 $GLOBALS['mnbt_plugin_meta'][$slug] = $meta ?: ['id' => $slug];
1127 try {
1128 include $boot;
1129 } catch (Throwable $e) {
1130 error_log('[MNBT plugin] boot ' . $slug . ': ' . $e->getMessage());
1131 }
1132 $GLOBALS['mnbt_plugin_current'] = null;
1133 }
1134 mnbt_do_action('boot');
1135 global $islogin, $islogins;
1136 if (isset($islogin) && (int)$islogin === 1) {
1137 mnbt_do_action('init.admin');
1138 }
1139 if (isset($islogins) && (int)$islogins === 1) {
1140 mnbt_do_action('init.user');
1141 }
1142}
1143
1144/**
1145 * ============================================================
1146 * V1.81 P2:首页接管与通用路由
1147 * ============================================================
1148 */
1149
1150/**
1151 * 计算当前请求相对于站点根目录的路径(去掉 base path 与查询串)。
1152 * 用于子目录部署:https://example.com/mnbt/landing?x=1 → /landing
1153 * @return array{path:string,method:string,base:string}
1154 */
1155function mnbt_plugin_request_info()
1156{
1157 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
1158 $basePath = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
1159 if ($basePath === '.' || $basePath === '/') {
1160 $basePath = '';
1161 }
1162 // 支持通过查询参数 _r 传递路由路径(无需 Web 服务器 rewrite 的兼容方案)
1163 if (isset($_GET['_r']) && is_string($_GET['_r']) && $_GET['_r'] !== '') {
1164 $path = $_GET['_r'];
1165 } else {
1166 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
1167 $path = parse_url($uri, PHP_URL_PATH) ?: '';
1168 // 去掉 base path 前缀
1169 if ($basePath !== '' && strpos($path, $basePath) === 0) {
1170 $path = substr($path, strlen($basePath));
1171 }
1172 }
1173 if ($path === '' || $path === false) {
1174 $path = '/';
1175 }
1176 // 规范化:确保以 / 开头
1177 if ($path !== '' && $path[0] !== '/') {
1178 $path = '/' . $path;
1179 }
1180 $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
1181 return ['path' => $path, 'method' => $method, 'base' => $basePath];
1182}
1183
1184/**
1185 * 注册首页接管回调。
1186 *
1187 * 回调签名:function (array $ctx): mixed
1188 * - 返回 string → 视为重定向 URL,引擎会 header("Location: ...") + exit
1189 * - 返回 true → 视为已渲染(回调内自行 echo),引擎直接 exit
1190 * - 返回 false/null → 不接管,继续下一个回调
1191 *
1192 * @param callable $callback
1193 * @param int $priority 数字越小越先执行(默认 10)
1194 * @return bool
1195 */
1196function mnbt_register_home($callback, $priority = 10)
1197{
1198 if (!is_callable($callback)) {
1199 return false;
1200 }
1201 $priority = (int)$priority;
1202 if (!isset($GLOBALS['mnbt_plugin_home_handlers'][$priority])) {
1203 $GLOBALS['mnbt_plugin_home_handlers'][$priority] = [];
1204 }
1205 $GLOBALS['mnbt_plugin_home_handlers'][$priority][] = [
1206 'cb' => $callback,
1207 'plugin' => $GLOBALS['mnbt_plugin_current'],
1208 ];
1209 return true;
1210}
1211
1212/**
1213 * 分发首页接管。
1214 * 由根目录 index.php 在请求路径为 / 时调用。
1215 *
1216 * @return bool true 表示已被插件接管(请求已终止);false 表示无插件接管,调用方走默认逻辑
1217 */
1218function mnbt_plugin_dispatch_home()
1219{
1220 if (empty($GLOBALS['mnbt_plugin_home_handlers'])) {
1221 return false;
1222 }
1223 $info = mnbt_plugin_request_info();
1224 // 仅当路径为 / 时才视作"首页"请求
1225 if ($info['path'] !== '/') {
1226 return false;
1227 }
1228 $buckets = $GLOBALS['mnbt_plugin_home_handlers'];
1229 ksort($buckets, SORT_NUMERIC);
1230 $ctx = [
1231 'path' => $info['path'],
1232 'method' => $info['method'],
1233 'base' => $info['base'],
1234 ];
1235 foreach ($buckets as $list) {
1236 foreach ($list as $item) {
1237 $prev = $GLOBALS['mnbt_plugin_current'];
1238 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
1239 try {
1240 $result = call_user_func($item['cb'], $ctx);
1241 } catch (Throwable $e) {
1242 error_log('[MNBT plugin] home @' . ($item['plugin'] ?? '?') . ': ' . $e->getMessage());
1243 $GLOBALS['mnbt_plugin_current'] = $prev;
1244 continue;
1245 }
1246 $GLOBALS['mnbt_plugin_current'] = $prev;
1247 // 返回字符串 → 重定向
1248 if (is_string($result) && $result !== '') {
1249 header('Location: ' . $result);
1250 exit;
1251 }
1252 // 返回 true → 已渲染
1253 if ($result === true) {
1254 exit;
1255 }
1256 // false / null / 其他 → 不接管,继续
1257 }
1258 }
1259 return false;
1260}
1261
1262/**
1263 * 注册通用路由。
1264 *
1265 * 路径支持命名参数:/promo/{id} → 匹配 /promo/123,回调收到 ['id'=>'123']
1266 * 路径必须以 / 开头;尾斜杠可选(自动同时匹配带/不带尾斜杠两种形式)。
1267 *
1268 * 回调签名:function (array $params, array $ctx): mixed
1269 * - 回调内自行 echo 输出,返回 true 或不返回(null)→ 引擎 exit 终止
1270 * - 返回 false → 不接管,继续匹配下一个路由
1271 *
1272 * @param string $method 'GET'/'POST'/'PUT'/'DELETE'/'HEAD'/'*'(* 匹配任意)
1273 * @param string $path 如 '/landing' 或 '/promo/{id}'
1274 * @param callable $callback
1275 * @param int $priority
1276 * @param string|callable|null $auth 鉴权要求:null/'none'=无验证, 'admin'=管理员, 'user'=用户, 回调函数=自定义验证
1277 * @return bool
1278 */
1279function mnbt_register_route($method, $path, $callback, $priority = 10, $auth = null, $csrfExempt = false)
1280{
1281 if (!is_callable($callback)) {
1282 return false;
1283 }
1284 $method = strtoupper((string)$method);
1285 if ($method === '') {
1286 $method = '*';
1287 }
1288 $path = (string)$path;
1289 if ($path === '' || $path[0] !== '/') {
1290 $path = '/' . $path;
1291 }
1292 $paramNames = [];
1293 $regex = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', function ($m) use (&$paramNames) {
1294 $paramNames[] = $m[1];
1295 return '([^/]+)';
1296 }, $path);
1297 $regex = '#^' . $regex . '/?$#';
1298 $priority = (int)$priority;
1299 if (!isset($GLOBALS['mnbt_plugin_routes'][$priority])) {
1300 $GLOBALS['mnbt_plugin_routes'][$priority] = [];
1301 }
1302 $GLOBALS['mnbt_plugin_routes'][$priority][] = [
1303 'method' => $method,
1304 'path' => $path,
1305 'regex' => $regex,
1306 'params' => $paramNames,
1307 'cb' => $callback,
1308 'plugin' => $GLOBALS['mnbt_plugin_current'],
1309 'auth' => $auth,
1310 'csrf_exempt' => (bool)$csrfExempt,
1311 ];
1312 return true;
1313}
1314
1315/**
1316 * 分发通用路由。
1317 * 由 index.php 或 _router.php 在请求未命中实际文件时调用。
1318 *
1319 * @return bool true 表示已匹配并由插件处理(请求已终止);false 表示无匹配
1320 */
1321function mnbt_plugin_dispatch_route()
1322{
1323 if (empty($GLOBALS['mnbt_plugin_routes'])) {
1324 return false;
1325 }
1326 $info = mnbt_plugin_request_info();
1327 $buckets = $GLOBALS['mnbt_plugin_routes'];
1328 ksort($buckets, SORT_NUMERIC);
1329 foreach ($buckets as $list) {
1330 foreach ($list as $item) {
1331 if ($item['method'] !== '*' && $item['method'] !== $info['method']) {
1332 continue;
1333 }
1334 if (!preg_match($item['regex'], $info['path'], $matches)) {
1335 continue;
1336 }
1337 $params = [];
1338 array_shift($matches);
1339 foreach ($item['params'] as $i => $name) {
1340 $params[$name] = isset($matches[$i]) ? $matches[$i] : '';
1341 }
1342 if (!mnbt_plugin_auth_check($item['auth'] ?? null)) {
1343 mnbt_plugin_auth_fail($item['auth'] ?? null);
1344 }
1345 mnbt_csrf_validate_request($item['csrf_exempt'] ?? false);
1346 $prev = $GLOBALS['mnbt_plugin_current'];
1347 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
1348 $ctx = [
1349 'path' => $info['path'],
1350 'method' => $info['method'],
1351 'base' => $info['base'],
1352 'plugin' => $item['plugin'],
1353 'route' => $item['path'],
1354 ];
1355 $bufferLevel = ob_get_level();
1356 ob_start('mnbt_csrf_inject_html');
1357 try {
1358 $result = call_user_func($item['cb'], $params, $ctx);
1359 if (is_string($result) && $result !== '') {
1360 if (!headers_sent()) header('Content-Type: text/html; charset=UTF-8');
1361 echo $result;
1362 }
1363 while (ob_get_level() > $bufferLevel) ob_end_flush();
1364 } catch (Throwable $e) {
1365 while (ob_get_level() > $bufferLevel) ob_end_clean();
1366 error_log('[MNBT plugin] route ' . $item['method'] . ' ' . $item['path'] . ' @' . ($item['plugin'] ?? '?') . ': ' . $e->getMessage());
1367 $GLOBALS['mnbt_plugin_current'] = $prev;
1368 // 路由已匹配但回调抛异常:渲染错误页终止请求,
1369 // 不再静默 continue 落到默认行为(曾导致用户端被跳转到核心虚拟主机登录页)
1370 if (!headers_sent()) {
1371 http_response_code(500);
1372 @header('Content-Type: text/html; charset=UTF-8');
1373 }
1374 echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>页面错误</title>'
1375 . '<style>body{font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f2f3f5;color:#1a2e28;display:grid;place-items:center;min-height:100vh;margin:0}'
1376 . '.box{max-width:680px;padding:32px 40px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.06)}'
1377 . 'h1{font-size:18px;margin:0 0 10px;color:#d54941}'
1378 . 'p{font-size:14px;line-height:1.7;color:#555;margin:8px 0}'
1379 . 'code{background:#f2f3f5;padding:2px 6px;border-radius:4px;font-size:12px}'
1380 . 'pre{background:#fafbfc;border:1px solid #eee;border-radius:8px;padding:14px;overflow:auto;font-size:12px;line-height:1.6;color:#333}</style></head>'
1381 . '<body><div class="box"><h1>页面处理出错</h1>'
1382 . '<p>路由 <code>' . htmlspecialchars($item['method'] . ' ' . $item['path'], ENT_QUOTES, 'UTF-8')
1383 . '</code> 处理异常,请稍后重试或联系管理员。</p>'
1384 . '<pre>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>'
1385 . '</div></body></html>';
1386 exit;
1387 }
1388 $GLOBALS['mnbt_plugin_current'] = $prev;
1389 if ($result === false) {
1390 continue;
1391 }
1392 exit;
1393 }
1394 }
1395 return false;
1396}
1397
1398/**
1399 * ============================================================
1400 * V1.81 P3:支付插件系统
1401 * ============================================================
1402 *
1403 * 支付方式 type 格式:{plugin_id}__{method_id}
1404 * 例:epay__alipay、alipay_official__pc
1405 *
1406 * 支付设置存储:MN_config.pay_methods 字段(JSON)
1407 * [{"plugin":"epay","method":"alipay","display_name":"支付宝","icon":"mdi-puzzle","sort":1}, ...]
1408 *
1409 * 插件 API 凭证存储:MN_plugin_option 表(通过 mnbt_plugin_option_get/set)
1410 */
1411
1412/**
1413 * 注册支付插件。
1414 *
1415 * @param string $plugin_id 插件标识(即 slug)
1416 * @param array $config [
1417 * 'name' => '易支付',
1418 * 'description' => '彩虹易支付协议',
1419 * 'methods' => [
1420 * 'alipay' => ['label'=>'支付宝', 'icon'=>'mdi-puzzle'],
1421 * 'wxpay' => ['label'=>'微信支付', 'icon'=>'mdi-wechat'],
1422 * ],
1423 * 'build' => function ($method, $order, $plugin_config) {
1424 * // $method: 'alipay'
1425 * // $order: ['out_trade_no'=>..., 'name'=>..., 'money'=>..., 'notify_url'=>..., 'return_url'=>..., 'order_row'=>...]
1426 * // $plugin_config: 该插件的所有选项(来自 MN_plugin_option)
1427 * // 返回 HTML 表单字符串,或返回 false 表示不接管
1428 * },
1429 * ]
1430 * @return bool
1431 */
1432function mnbt_register_payment($plugin_id, $config)
1433{
1434 $plugin_id = (string)$plugin_id;
1435 if ($plugin_id === '' || !is_array($config)) {
1436 return false;
1437 }
1438 // 校验 build 回调
1439 if (!isset($config['build']) || !is_callable($config['build'])) {
1440 return false;
1441 }
1442 if (!isset($config['methods']) || !is_array($config['methods'])) {
1443 $config['methods'] = [];
1444 }
1445 $config['plugin_id'] = $plugin_id;
1446 $GLOBALS['mnbt_plugin_payments'][$plugin_id] = $config;
1447 return true;
1448}
1449
1450/**
1451 * 获取所有已注册的支付插件。
1452 * @return array ['epay' => ['name'=>..., 'methods'=>[...]], ...]
1453 */
1454function mnbt_get_payment_plugins()
1455{
1456 return isset($GLOBALS['mnbt_plugin_payments']) ? $GLOBALS['mnbt_plugin_payments'] : [];
1457}
1458
1459/**
1460 * 构造支付方式的 type 标识。
1461 * @param string $plugin_id
1462 * @param string $method_id
1463 * @return string 如 "epay__alipay"
1464 */
1465function mnbt_pay_type($plugin_id, $method_id)
1466{
1467 return $plugin_id . '__' . $method_id;
1468}
1469
1470/**
1471 * 从 type 解析出 plugin_id 和 method_id。
1472 * @param string $type
1473 * @return array|false ['plugin'=>'epay', 'method'=>'alipay'] 或 false
1474 */
1475function mnbt_pay_parse_type($type)
1476{
1477 $type = (string)$type;
1478 if (strpos($type, '__') === false) {
1479 return false;
1480 }
1481 $parts = explode('__', $type, 2);
1482 if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
1483 return false;
1484 }
1485 return ['plugin' => $parts[0], 'method' => $parts[1]];
1486}
1487
1488/**
1489 * 获取已启用的付款方式列表(从 MN_config.pay_methods JSON 解析)。
1490 * @return array [['plugin'=>..., 'method'=>..., 'display_name'=>..., 'icon'=>..., 'sort'=>...], ...] 按 sort 排序
1491 */
1492function mnbt_get_enabled_payment_methods()
1493{
1494 global $DB, $siteid;
1495 if (!isset($DB)) {
1496 return [];
1497 }
1498 $siteid = isset($siteid) ? $siteid : 1;
1499 $row = $DB->get_row_prepare("SELECT pay_methods FROM MN_config WHERE id=? LIMIT 1", [$siteid]);
1500 if (!$row || empty($row['pay_methods'])) {
1501 return [];
1502 }
1503 $list = json_decode($row['pay_methods'], true);
1504 if (!is_array($list)) {
1505 return [];
1506 }
1507 // 按 sort 排序
1508 usort($list, function ($a, $b) {
1509 $sa = isset($a['sort']) ? (int)$a['sort'] : 99;
1510 $sb = isset($b['sort']) ? (int)$b['sort'] : 99;
1511 return $sa - $sb;
1512 });
1513 return $list;
1514}
1515
1516/**
1517 * 保存付款方式配置到 MN_config.pay_methods。
1518 * @param array $methods [['plugin'=>..., 'method'=>..., 'display_name'=>..., 'icon'=>..., 'sort'=>...], ...]
1519 * @return bool
1520 */
1521function mnbt_save_payment_methods($methods)
1522{
1523 global $DB, $siteid;
1524 if (!isset($DB)) {
1525 return false;
1526 }
1527 $siteid = isset($siteid) ? $siteid : 1;
1528 $json = json_encode($methods, JSON_UNESCAPED_UNICODE);
1529 return (bool)$DB->query_prepare("UPDATE MN_config SET pay_methods=? WHERE id=?", [$json, $siteid]);
1530}
1531
1532/**
1533 * 分发支付网关:根据 type 找到对应插件的 build 回调并调用。
1534 *
1535 * @param string $type 支付方式 type,如 epay__alipay
1536 * @param array $order_context 订单上下文 ['out_trade_no'=>..., 'name'=>..., 'money'=>..., ...]
1537 * @return string|false HTML 表单字符串,或 false 表示无插件接管
1538 */
1539function mnbt_pay_dispatch_gateway($type, $order_context)
1540{
1541 // V1.84: 支付分发前统一钩子,供实名认证等插件在支付发起前拦截(无插件注册时返回 null 直接放行)
1542 $guard = mnbt_apply_filters('pay.dispatch.before', null, $type, $order_context);
1543 if (is_string($guard) && $guard !== '') {
1544 return $guard;
1545 }
1546 $parsed = mnbt_pay_parse_type($type);
1547 if (!$parsed) {
1548 return false;
1549 }
1550 $plugin_id = $parsed['plugin'];
1551 $method_id = $parsed['method'];
1552 if (!isset($GLOBALS['mnbt_plugin_payments'][$plugin_id])) {
1553 return false;
1554 }
1555 $payment = $GLOBALS['mnbt_plugin_payments'][$plugin_id];
1556 if (!isset($payment['methods'][$method_id])) {
1557 return false;
1558 }
1559 $cb = $payment['build'];
1560 if (!is_callable($cb)) {
1561 return false;
1562 }
1563 // 加载插件选项作为配置
1564 $plugin_config = function_exists('mnbt_plugin_option_all') ? mnbt_plugin_option_all($plugin_id) : [];
1565 $prev = isset($GLOBALS['mnbt_plugin_current']) ? $GLOBALS['mnbt_plugin_current'] : null;
1566 $GLOBALS['mnbt_plugin_current'] = $plugin_id;
1567 try {
1568 $result = call_user_func($cb, $method_id, $order_context, $plugin_config);
1569 } catch (Throwable $e) {
1570 error_log('[MNBT plugin] payment build @' . $plugin_id . '::' . $method_id . ': ' . $e->getMessage());
1571 $GLOBALS['mnbt_plugin_current'] = $prev;
1572 return false;
1573 }
1574 $GLOBALS['mnbt_plugin_current'] = $prev;
1575 return $result;
1576}