fetchAll() as $row) {
$id = (string)($row['id'] ?? '');
$file = str_replace('\\', '/', ltrim((string)($row['file'] ?? ''), '/'));
$manifest = plugin_json_decode($row['manifest_json'] ?? '', null);
if (!self::plugin_id_valid($id) || $id === 'plugin_market' || $file !== 'app/plugins/' . $id . '/plugin.php' || $manifest === null) continue;
$rows[] = [
'id' => $id,
'file' => $file,
'config' => plugin_json_decode($row['config_json'] ?? '') ?? [],
'entries' => plugin_json_decode($row['entries_json'] ?? '') ?? [],
'hooks' => is_array($manifest['hooks'] ?? null) ? $manifest['hooks'] : [],
'links' => is_array($manifest['links'] ?? null) ? $manifest['links'] : [],
'routes' => is_array($manifest['routes'] ?? null) ? $manifest['routes'] : [],
'admin_tabs' => is_array($manifest['admin_tabs'] ?? null) ? $manifest['admin_tabs'] : [],
'assets' => is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [],
'assets_mode' => self::plugin_assets_mode_normalize($manifest['assets_mode'] ?? ''),
'cron' => is_array($manifest['cron'] ?? null) ? $manifest['cron'] : [],
];
}
save_settings_values(['cache_plugins' => plugin_json_encode($rows)]);
return $rows;
}
public static function plugin_update_row(string $id, array $values, bool $touch_updated_at = true): void
{
if (!self::plugin_id_valid($id)) throw new InvalidArgumentException('插件 ID 无效');
$allowed = array_flip(['name', 'version', 'file', 'code_hash', 'manifest_json', 'config_json', 'entries_json', 'enabled', 'status', 'disabled_reason', 'installed_at']);
if (array_diff_key($values, $allowed)) throw new InvalidArgumentException('插件字段无效');
foreach (['manifest_json', 'config_json', 'entries_json'] as $field) {
if (array_key_exists($field, $values)) $values[$field] = plugin_json_encode(plugin_json_decode($values[$field]) ?? []);
}
if (!$values) return;
if ($touch_updated_at) $values['updated_at'] = now();
$fields = array_keys($values);
q('UPDATE app_plugins SET ' . implode('=?,', $fields) . '=? WHERE id=?', array_merge(array_values($values), [$id]));
}
private static function plugin_registry_row(array $row): ?array
{
$id = (string)($row['id'] ?? '');
$file = str_replace('\\', '/', ltrim((string)($row['file'] ?? ''), '/'));
$manifest = plugin_json_decode($row['manifest_json'] ?? '', null);
if (!self::plugin_id_valid($id) || $file !== 'app/plugins/' . $id . '/plugin.php' || $manifest === null) return null;
return array_merge($manifest, [
'id' => $id,
'name' => (string)$row['name'],
'version' => (string)$row['version'],
'enabled' => (int)$row['enabled'] === 1,
'disabled_reason' => (string)($row['disabled_reason'] ?? ''),
'updated_at' => (int)($row['updated_at'] ?? 0),
'file' => APP_ROOT . '/' . $file,
'config' => plugin_json_decode($row['config_json'] ?? '') ?? [],
'entries' => plugin_json_decode($row['entries_json'] ?? '') ?? [],
]);
}
public static function plugin_id_valid(string $id): bool
{
return preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $id) === 1;
}
public static function plugin_load(array $plugin): void
{
$file = (string)($plugin['file'] ?? '');
if ($file === '' || !is_file($file)) return;
if (array_key_exists($file, $GLOBALS['__plugin_raw'] ?? [])) return;
$GLOBALS['__plugin_raw'][$file] = include $file;
}
public static function plugin_callback_exists(mixed $callback): bool
{
return is_string($callback) && $callback !== '' && function_exists($callback);
}
public static function plugin_call(array $plugin, callable $callback): mixed
{
try {
self::plugin_load($plugin);
return $callback();
} catch (Throwable $e) {
self::plugin_disable_after_exception((string)($plugin['id'] ?? ''), $e);
throw $e;
}
}
public static function plugins(bool $refresh = false): array
{
if (!$refresh && self::$plugins_cache !== null) return self::$plugins_cache;
$plugins = [];
foreach (self::plugin_runtime_cache_rows($refresh) as $plugin) {
$id = (string)$plugin['id'];
$plugin['file'] = APP_ROOT . '/' . (string)$plugin['file'];
$plugin['enabled'] = true;
$plugins[$id] = $plugin;
}
plugin_runtime_cache_reset();
return self::$plugins_cache = $plugins;
}
public static function plugin_enabled(array $plugin): bool
{
return !empty($plugin['enabled']);
}
public static function plugin_entry_definitions(): array
{
return [
'feature_links' => ['hook' => 'sidebar.feature_links', 'label' => '快捷功能', 'links' => true],
'sidebar_cards' => ['hook' => 'sidebar.stack', 'label' => '边栏卡片'],
'home_tabs' => ['hook' => 'topic.index_tabs', 'label' => '首页Tab', 'links' => true],
'profile_tabs' => ['hook' => 'user.profile_tabs', 'label' => '个人主页Tab', 'links' => true],
'profile_settings_tabs' => ['hook' => 'profile.settings_tabs', 'label' => '个人设置Tab', 'links' => true],
'profile_card' => ['hook' => 'user.menu_links', 'label' => '个人卡片', 'links' => true],
'topic_actions' => ['hook' => 'topic.actions', 'label' => '主题操作'],
'admin_tabs' => ['hook' => 'admin.tabs', 'label' => '后台Tab', 'links' => true],
'top_menu' => ['hook' => 'top.menu_links', 'label' => '顶部菜单', 'links' => true],
'top_actions' => ['hook' => 'top.bar.actions', 'label' => '顶部栏入口'],
];
}
public static function plugin_entry_hook_name(string $entry): string
{
return (string)(self::plugin_entry_definitions()[$entry]['hook'] ?? '');
}
public static function plugin_uses_entry(array $plugin, string $entry): bool
{
$hook = self::plugin_entry_hook_name($entry);
return $hook !== '' && (isset($plugin['hooks'][$hook]) || !empty($plugin['links'][$entry]));
}
public static function plugin_entry_links(array $plugin, string $entry): array
{
$definition = self::plugin_entry_definitions()[$entry] ?? null;
if (empty($definition['links'])) return [];
$links = $plugin['links'][$entry] ?? null;
return is_array($links) ? array_values($links) : [];
}
public static function plugin_entry_is_tab(string $entry): bool
{
return in_array($entry, ['admin_tabs', 'home_tabs', 'profile_tabs', 'profile_settings_tabs'], true);
}
public static function plugin_entry_enabled(array $plugin, string $entry): bool
{
if (!self::plugin_uses_entry($plugin, $entry)) return false;
return !array_key_exists($entry, (array)($plugin['entries'] ?? [])) || !empty($plugin['entries'][$entry]);
}
public static function plugin_entry_links_apply(mixed $value, array $plugin, string $entry, array $ctx): array
{
$items = self::plugin_entry_links($plugin, $entry);
$out = is_array($value) ? $value : [];
if (!$items) return $out;
$tab = self::plugin_entry_is_tab($entry);
foreach ($items as $item) {
if (!self::plugin_entry_link_visible($item, $plugin, $ctx)) continue;
$text = (string)($item['text'] ?? '');
$text_config = (string)($item['text_config'] ?? '');
if ($text_config !== '') {
$values = is_array($plugin['config'] ?? null) ? $plugin['config'] : [];
$value = $values[$text_config] ?? null;
$value = is_scalar($value) ? trim(cut(strip_tags((string)$value), 60)) : '';
if ($value !== '') $text = $value;
}
if ($text === '') continue;
$url = (string)($item['url'] ?? '');
if ($url === '' && (string)($item['route'] ?? '') !== '') {
$params = (array)($item['params'] ?? []);
if ($params) {
$params = self::plugin_link_params_resolve($params, $ctx);
if ($params === null) continue;
}
$url = route_url((string)$item['route'], $params);
}
if ($url === '') continue;
if ($tab) {
$key = (string)($item['key'] ?? '');
if ($key === '') continue;
$tab_item = ['label' => h($text), 'href' => $url];
$before = (string)($item['before'] ?? '');
$after = (string)($item['after'] ?? '');
if ($before !== '' && array_key_exists($before, $out)) {
$next = [];
foreach ($out as $k => $v) {
if ($k === $before) $next[$key] = $tab_item;
$next[$k] = $v;
}
$out = $next;
} elseif ($after !== '' && array_key_exists($after, $out)) {
$next = [];
foreach ($out as $k => $v) {
$next[$k] = $v;
if ($k === $after) $next[$key] = $tab_item;
}
$out = $next;
} else {
$out[$key] = $tab_item;
}
continue;
}
$row = ['text' => $text, 'url' => $url];
if (!empty($item['icon'])) $row['icon'] = (string)$item['icon'];
if (!empty($item['icon_html'])) $row['icon_html'] = (string)$item['icon_html'];
if (!empty($item['badge'])) $row['badge'] = (string)$item['badge'];
if (!empty($item['badge_dot'])) $row['badge_dot'] = true;
$out[] = $row;
}
return $out;
}
private static function plugin_entry_link_visible(array $item, array $plugin, array $ctx): bool
{
$when = is_array($item['when'] ?? null) ? $item['when'] : [];
if (!empty($when['login']) && !uid()) return false;
if (array_key_exists('admin', $when) && (bool)can_access_admin() !== (bool)$when['admin']) return false;
if (!empty($when['self']) && empty($ctx['self'])) return false;
if (!self::plugin_entry_rules_pass($when['config'] ?? null, (array)($plugin['config'] ?? []))) return false;
if (!self::plugin_entry_rules_pass($when['ctx'] ?? null, $ctx)) return false;
return true;
}
private static function plugin_entry_rules_pass(mixed $raw, array $values): bool
{
if ($raw === null) return true;
$rules = is_array($raw) && array_is_list($raw) ? $raw : [$raw];
foreach ($rules as $rule) {
if (!is_array($rule)) continue;
$key = (string)($rule['key'] ?? '');
if ($key === '') continue;
$value = self::plugin_rule_value($values, $key);
if ($value === null && !array_key_exists($key, $values)) $value = $rule['default'] ?? null;
if (array_key_exists('equals', $rule)) {
$equals = $rule['equals'];
$matched = is_bool($equals) ? (bool)$value === $equals : is_scalar($value) && (string)$value === (string)$equals;
if (!$matched) return false;
} elseif (empty($value)) return false;
}
return true;
}
private static function plugin_rule_value(array $values, string $key): mixed
{
if (array_key_exists($key, $values)) return $values[$key];
if (!str_contains($key, '.')) return null;
$value = $values;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) return null;
$value = $value[$part];
}
return $value;
}
private static function plugin_link_params_resolve(array $params, array $ctx): ?array
{
$out = [];
foreach ($params as $key => $value) {
if (is_string($value) && preg_match('/^\{([a-z0-9_]{1,32}(?:\.[a-z0-9_]{1,32})?)\}$/', $value, $m)) {
$resolved = self::plugin_link_ctx_value($m[1], $ctx);
if ($resolved === null || $resolved === '') return null;
$out[$key] = $resolved;
continue;
}
$out[$key] = $value;
}
return $out;
}
private static function plugin_link_ctx_value(string $path, array $ctx): int|string|null
{
if ($path === 'uid') return uid() ?: null;
if (str_starts_with($path, 'user.')) {
$user = is_array($ctx['user'] ?? null) ? $ctx['user'] : [];
$value = $user[substr($path, 5)] ?? null;
return is_scalar($value) ? $value : null;
}
$value = $ctx[$path] ?? null;
if (is_bool($value)) return $value ? 1 : 0;
return is_scalar($value) ? $value : null;
}
public static function hook_ctx_prepare(string $name, array $ctx, array $stack = []): array
{
$parent = $stack[$name] ?? null;
if (!is_array($parent)) {
$from = $name === 'topic.index_data.loaded' ? 'topic.index_data.load' : ($name === 'topic.replies_data.loaded' ? 'topic.replies_data.load' : '');
if ($from !== '') $parent = $stack[$from] ?? null;
}
if (is_array($parent)) $ctx += $parent;
$kind = str_starts_with($name, 'topic.') ? 'topic' : (str_starts_with($name, 'reply.') ? 'reply' : '');
if ($kind === '') return $ctx;
$row = is_array($ctx['row'] ?? null) ? $ctx['row'] : [];
$topic = is_array($ctx['topic'] ?? null) ? $ctx['topic'] : [];
if ((int)($ctx['topic_id'] ?? 0) <= 0) {
$topic_id = $kind === 'topic' ? (int)($row['id'] ?? 0) : (int)($row['topic_id'] ?? 0);
if ($topic_id <= 0) $topic_id = (int)($topic['id'] ?? 0);
if ($topic_id > 0) $ctx['topic_id'] = $topic_id;
}
if ((int)($ctx['forum_id'] ?? 0) <= 0) {
$forum_id = (int)($row['forum_id'] ?? 0);
if ($forum_id <= 0) $forum_id = (int)($topic['forum_id'] ?? 0);
if ($forum_id > 0) $ctx['forum_id'] = $forum_id;
}
return $ctx;
}
public static function hook_rows_snapshot(mixed $value): array
{
if (!is_array($value) || !is_array($value['rows'] ?? null)) return [];
$snapshot = [];
foreach ($value['rows'] as $row) {
$id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
if ($id <= 0) continue;
$snapshot[$id] = array_intersect_key($row, array_flip(['id', 'topic_id', 'forum_id']));
}
return $snapshot;
}
public static function hook_rows_restore(mixed $value, array $snapshot): mixed
{
if (!$snapshot || !is_array($value) || !is_array($value['rows'] ?? null)) return $value;
foreach ($value['rows'] as $i => $row) {
$id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
if ($id <= 0 || !isset($snapshot[$id])) continue;
$value['rows'][$i] = $row + $snapshot[$id];
}
return $value;
}
public static function plugin_registry(?string $id = null, bool $refresh = false): array
{
if ($id !== null && !self::plugin_id_valid($id)) return [];
if ($refresh) self::$registry_cache = [];
$key = $id === null ? '*' : $id;
if (isset(self::$registry_cache[$key])) return self::$registry_cache[$key];
if ($id !== null && isset(self::$registry_cache['*'])) {
return isset(self::$registry_cache['*'][$id]) ? [$id => self::$registry_cache['*'][$id]] : [];
}
$plugins = [];
$sql = "SELECT id,name,version,file,manifest_json,config_json,entries_json,enabled,disabled_reason,updated_at FROM app_plugins";
$rows = q($sql . ($id === null ? ' ORDER BY id' : ' WHERE id=?'), $id === null ? [] : [$id])->fetchAll();
foreach ($rows as $row) {
if ((string)$row['id'] === 'plugin_market') continue;
$plugin = self::plugin_registry_row($row);
if ($plugin) $plugins[(string)$plugin['id']] = $plugin;
}
self::$registry_cache[$key] = $plugins;
return $plugins;
}
public static function plugin_registry_flush(): void
{
self::$registry_cache = [];
}
public static function plugin_market_url(string $action): string
{
return append_url_query(PLUGIN_MARKET_ENDPOINT, ['a' => $action]);
}
public static function remote_http_request(string $url, int $timeout = 8, array $headers = [], ?array $post_fields = null, array $options = []): array
{
if (!function_exists('curl_init')) return ['ok' => false, 'status' => 0, 'body' => '', 'error' => '服务器未启用 cURL'];
$timeout = max(1, $timeout);
$connect_timeout = min($timeout, max(1, (int)($options['connect_timeout'] ?? min(4, $timeout))));
$max_bytes = max(0, (int)($options['max_bytes'] ?? REMOTE_HTTP_DEFAULT_MAX_BYTES));
$user_agent = trim((string)($options['user_agent'] ?? '')) ?: 'bbs1org/' . APP_VERSION;
$ch = curl_init($url);
if (!$ch) return ['ok' => false, 'status' => 0, 'body' => '', 'error' => '无法初始化请求'];
$body = '';
$too_large = false;
$options = [
CURLOPT_RETURNTRANSFER => $max_bytes === 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => $connect_timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_USERAGENT => $user_agent,
];
if ($max_bytes > 0) {
$options[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use (&$body, &$too_large, $max_bytes): int {
if (strlen($body) + strlen($chunk) > $max_bytes) {
$too_large = true;
return 0;
}
$body .= $chunk;
return strlen($chunk);
};
}
if (setting('ignore_ssl_errors') === '1') {
$options[CURLOPT_SSL_VERIFYPEER] = false;
$options[CURLOPT_SSL_VERIFYHOST] = 0;
}
if ($headers) $options[CURLOPT_HTTPHEADER] = $headers;
if ($post_fields !== null) {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = http_build_query($post_fields);
}
if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
if (defined('CURLOPT_REDIR_PROTOCOLS') && defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
curl_setopt_array($ch, $options);
$result = curl_exec($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($too_large) return ['ok' => false, 'status' => $status, 'body' => '', 'error' => '响应内容过大'];
if ($result === false) return ['ok' => false, 'status' => $status, 'body' => '', 'error' => $error !== '' ? $error : '请求失败'];
if ($max_bytes === 0) $body = (string)$result;
if ($status < 200 || $status >= 300) return ['ok' => false, 'status' => $status, 'body' => (string)$body, 'error' => 'HTTP ' . $status];
return ['ok' => true, 'status' => $status, 'body' => (string)$body, 'error' => ''];
}
private static function plugin_market_view(string $view): string
{
return $view === 'beta' ? 'beta' : 'certified';
}
private static function plugin_market_view_param(string $market_view): ?string
{
return $market_view !== 'certified' ? $market_view : null;
}
private static function plugin_market_topic_url(string $plugin_id, int $topic_id, string $market_view): string
{
$market = self::plugin_market_cache_read(false, self::plugin_market_view($market_view))
?? self::plugin_market_cache_read(false, self::plugin_market_view($market_view) === 'beta' ? 'certified' : 'beta');
$item = is_array($market) ? ($market['plugins'][$plugin_id] ?? null) : null;
if (!is_array($item)) return '';
if ($topic_id > 0 && (int)($item['topic_id'] ?? 0) > 0 && (int)$item['topic_id'] !== $topic_id) return '';
$url = (string)($item['url'] ?? '');
if ($url === '') return '';
$site = parse_url(PLUGIN_MARKET_ENDPOINT);
$target = parse_url($url);
if (!is_array($site) || !is_array($target)) return '';
$host = strtolower((string)($site['host'] ?? ''));
$target_host = strtolower((string)($target['host'] ?? ''));
if ($host === '' || ($target_host !== $host && !str_ends_with($target_host, '.' . $host))) return '';
return $url;
}
private static function plugin_market_cache_file(string $market_view = 'certified'): string
{
return DATA_DIR . '/' . PLUGIN_MARKET_CACHE_FILE . ($market_view === 'beta' ? '-beta' : '');
}
private static function plugin_market_cache_read(bool $fresh, string $market_view = 'certified'): ?array
{
$file = self::plugin_market_cache_file($market_view);
if (!is_file($file)) return null;
$data = json_decode((string)@file_get_contents($file), true);
if (!is_array($data) || !is_array($data['plugins'] ?? null) || !$data['plugins']) return null;
$fetched_at = (int)($data['fetched_at'] ?? 0);
if ($fetched_at < 1) return null;
if ($fresh && now() - $fetched_at >= PLUGIN_MARKET_CACHE_TTL) return null;
return $data;
}
private static function plugin_market_cache_write(array $data, string $market_view = 'certified'): void
{
$data['fetched_at'] = now();
@file_put_contents(self::plugin_market_cache_file($market_view), json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), LOCK_EX);
}
public static function plugin_market_fetch(bool $need_code = false, bool $force = false, string $plugin_id = '', int $topic_id = 0, string $market_view = 'certified', string $authorization_code = ''): array
{
$market_view = self::plugin_market_view($market_view);
if (!$need_code && !$force) {
$cached = self::plugin_market_cache_read(true, $market_view);
if ($cached !== null) return $cached;
}
$feed_url = append_url_query(self::plugin_market_url('plugin_market_feed'), ['mode' => $need_code ? 'install' : 'list', 'market_view' => $market_view === 'beta' ? 'beta' : null]);
if ($need_code) {
if (!self::plugin_id_valid($plugin_id) || $topic_id < 1) return ['ok' => 0, 'message' => '插件市场参数无效', 'plugins' => []];
$feed_url = append_url_query($feed_url, ['id' => $plugin_id, 'topic_id' => $topic_id, 'authorization_code' => trim($authorization_code) !== '' ? trim($authorization_code) : null]);
}
$response = self::remote_http_request($feed_url, $need_code ? 8 : 5, ['Accept: application/json']);
if (!$response['ok']) {
if (!$need_code) {
$stale = self::plugin_market_cache_read(false, $market_view);
if ($stale !== null) return $stale;
}
return ['ok' => 0, 'message' => '无法连接插件市场' . ((string)$response['error'] !== '' ? ':' . (string)$response['error'] : ''), 'plugins' => []];
}
$data = json_decode((string)$response['body'], true);
if (!is_array($data)) return ['ok' => 0, 'message' => '插件市场返回格式错误', 'plugins' => []];
$plugins = [];
foreach ((array)($data['plugins'] ?? []) as $item) {
if (!is_array($item)) continue;
$id = (string)($item['id'] ?? '');
$code = (string)($item['code'] ?? '');
$price_points = max(0, (int)($item['price_points'] ?? 0));
$online_install = !array_key_exists('online_install', $item) ? $price_points === 0 : !empty($item['online_install']);
$item_market_view = self::plugin_market_view((string)($item['market_status'] ?? 'certified'));
if (!self::plugin_id_valid($id) || $id === 'plugin_market' || ($need_code && $online_install && trim($code) === '')) continue;
$plugins[$id] = [
'id' => $id,
'title' => (string)($item['title'] ?? ''),
'name' => (string)($item['name'] ?? $id),
'version' => (string)($item['version'] ?? ''),
'assets_mode' => self::plugin_assets_mode_normalize($item['assets_mode'] ?? ''),
'description' => (string)($item['description'] ?? ''),
'author' => (string)($item['author'] ?? ''),
'creator' => (string)($item['creator'] ?? ($item['username'] ?? ($item['author'] ?? ''))),
'creator_id' => (int)($item['creator_id'] ?? 0),
'topic_id' => (int)($item['topic_id'] ?? 0),
'price_points' => $price_points,
'online_install' => $online_install,
'market_status' => $item_market_view,
'required' => $item_market_view === 'certified' && !empty($item['required']),
'updated_at' => (int)($item['updated_at'] ?? 0),
'sha256' => (string)($item['sha256'] ?? hash('sha256', $code)),
'url' => clean_site_base_url((string)($item['url'] ?? '')),
];
if ($need_code && $online_install) $plugins[$id]['code'] = $code;
}
$result = ['ok' => (int)($data['ok'] ?? 1), 'message' => (string)($data['message'] ?? ''), 'plugins' => $plugins];
if (!$need_code && (int)$result['ok'] === 1 && $plugins) {
$result['fetched_at'] = now();
self::plugin_market_cache_write($result, $market_view);
return $result;
}
if (!$need_code) {
$stale = self::plugin_market_cache_read(false, $market_view);
if ($stale !== null) return $stale;
}
return $result;
}
private static function plugin_manifest_code_id(string $code): string
{
$tokens = token_get_all($code);
$return_arrays = [];
$brace_depth = 0;
$count = count($tokens);
$next_code_token = static function (int $offset) use ($tokens, $count): int {
for ($i = $offset; $i < $count; $i++) {
if (!is_array($tokens[$i]) || !in_array($tokens[$i][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) return $i;
}
return -1;
};
$literal_value = static function ($token): ?string {
if (!is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING || strlen($token[1]) < 2) return null;
$quote = $token[1][0];
if ($quote !== "'" && $quote !== '"') return null;
$value = substr($token[1], 1, -1);
return $quote === "'" ? (string)preg_replace('/\\\\([\\\\\'])/', '$1', $value) : stripcslashes($value);
};
$constants = [];
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if ($token === '{' || (is_array($token) && in_array($token[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
$brace_depth++;
continue;
}
if ($token === '}') {
$brace_depth = max(0, $brace_depth - 1);
continue;
}
if ($brace_depth === 0 && is_array($token) && $token[0] === T_CONST) {
for ($j = $i + 1; $j < $count && $tokens[$j] !== ';'; $j++) {
if (!is_array($tokens[$j]) || $tokens[$j][0] !== T_STRING) continue;
$equal = $next_code_token($j + 1);
$value = $equal >= 0 && $tokens[$equal] === '=' ? $next_code_token($equal + 1) : -1;
if ($value >= 0 && ($decoded = $literal_value($tokens[$value])) !== null) $constants[$tokens[$j][1]] = $decoded;
}
continue;
}
if ($brace_depth === 0 && is_array($token) && $token[0] === T_STRING && strcasecmp($token[1], 'define') === 0) {
$open = $next_code_token($i + 1);
$name_at = $open >= 0 && $tokens[$open] === '(' ? $next_code_token($open + 1) : -1;
$comma = $name_at >= 0 ? $next_code_token($name_at + 1) : -1;
$value = $comma >= 0 && $tokens[$comma] === ',' ? $next_code_token($comma + 1) : -1;
$name = $name_at >= 0 ? $literal_value($tokens[$name_at]) : null;
$decoded = $value >= 0 ? $literal_value($tokens[$value]) : null;
if ($name !== null && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name) === 1 && $decoded !== null) $constants[$name] = $decoded;
}
if ($brace_depth !== 0 || !is_array($token) || $token[0] !== T_RETURN) continue;
$start = $next_code_token($i + 1);
if ($start < 0) continue;
if ($tokens[$start] === '[') {
$return_arrays[] = $start;
continue;
}
if (is_array($tokens[$start]) && $tokens[$start][0] === T_ARRAY) {
$open = $next_code_token($start + 1);
if ($open >= 0 && $tokens[$open] === '(') $return_arrays[] = $open;
}
}
foreach (array_reverse($return_arrays) as $start) {
$depth = 1;
for ($i = $start + 1; $i < $count && $depth > 0; $i++) {
$token = $tokens[$i];
if (in_array($token, ['[', '(', '{'], true) || (is_array($token) && in_array($token[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
$depth++;
continue;
}
if (in_array($token, [']', ')', '}'], true)) {
$depth--;
continue;
}
if ($depth !== 1 || $literal_value($token) !== 'id') continue;
$arrow = $next_code_token($i + 1);
$value = $arrow >= 0 && is_array($tokens[$arrow]) && $tokens[$arrow][0] === T_DOUBLE_ARROW ? $next_code_token($arrow + 1) : -1;
if ($value < 0) return '';
$id = $literal_value($tokens[$value]);
if ($id === null && is_array($tokens[$value]) && $tokens[$value][0] === T_STRING) $id = $constants[$tokens[$value][1]] ?? null;
return is_string($id) && preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/D', $id) === 1 ? $id : '';
}
}
return '';
}
public static function plugin_market_install(string $id, int $topic_id, bool $auto_enable = false, string $market_view = 'certified', string $authorization_code = ''): void
{
if (!self::plugin_id_valid($id)) err('插件不存在');
if ($id === 'plugin_market') err('该插件 ID 为系统保留');
require_writable_dir(PLUGIN_DIR, '插件目录不可写,请检查 app/plugins/ 目录权限');
$market_view = self::plugin_market_view($market_view);
$market = self::plugin_market_fetch(true, true, $id, $topic_id, $market_view, $authorization_code);
$item = $market['plugins'][$id] ?? null;
if (!is_array($item)) err((string)($market['message'] ?? '') ?: '插件市场没有返回该插件');
if (empty($item['online_install'])) err((int)($item['price_points'] ?? 0) > 0 ? '授权码无效、已使用或已过期,请在插件页面重新获取' : '该插件暂不支持在线安装');
$code = (string)($item['code'] ?? '');
if (!str_starts_with(ltrim($code), ' $target_enabled, 'status' => $target_enabled ? 'enabled' : 'disabled', 'disabled_reason' => ''], true);
q("UPDATE app_cron_tasks SET enabled=? WHERE plugin_id=?", [$target_enabled, $id]);
save_settings_values([
'plugin_' . $id . '_market_sha256' => (string)$item['sha256'],
'plugin_' . $id . '_market_topic_id' => (string)(int)$item['topic_id'],
'plugin_sync_pending' => self::plugin_sync_pending_mark($id),
'plugin_force_sync' => self::plugin_force_sync_mark($id),
]);
self::plugin_registry_flush();
if (!$auto_enable) return;
if ($plugin_file_loaded) return;
self::plugin_registry_sync([$id], true);
self::plugin_force_sync_clear($id);
self::plugin_set_enabled($id, true, true);
self::plugin_assets_rebuild([$id]);
save_settings_values(['plugin_sync_pending' => '0']);
}
public static function plugin_market_install_page(): void
{
need_admin();
require_post();
$auto_enable = (string)($_POST['auto_enable'] ?? '') === '1';
$raw_view = (string)($_POST['market_view'] ?? '');
$market_view = self::plugin_market_view($raw_view);
$return_view = $raw_view === 'required' ? 'required' : ($market_view === 'beta' ? 'beta' : null);
$plugin_id = (string)($_POST['plugin_id'] ?? '');
$topic_id = max(0, (int)($_POST['topic_id'] ?? 0));
$authorization_code = trim((string)($_POST['authorization_code'] ?? ''));
if ($authorization_code === '') {
$requires_authorization = (string)($_POST['authorization_required'] ?? '') === '1';
if (!$requires_authorization) {
$market = self::plugin_market_fetch(false, true, '', 0, $market_view);
$item = is_array($market['plugins'][$plugin_id] ?? null) ? $market['plugins'][$plugin_id] : null;
$requires_authorization = is_array($item) && (int)($item['price_points'] ?? 0) > 0;
}
if ($requires_authorization) {
$topic_url = self::plugin_market_topic_url($plugin_id, $topic_id, $market_view);
$link = $topic_url !== ''
? '获取授权码'
: '';
$tip = '
' . ($link !== ''
? '在新的页面里购买并点击“授权码”,复制后关闭该页面,回到这里粘贴即可。'
: '请在插件页面购买后点击“授权码”获取,复制后回到这里粘贴即可。') . '
';
$form = '';
json_response(['ok' => 1, 'modal' => ['title' => '填写授权码', 'html' => $form]]);
}
}
self::plugin_market_install($plugin_id, $topic_id, $auto_enable, $market_view, $authorization_code);
$message = $auto_enable ? '插件已安装或更新并启用。' : '插件已安装或更新,已停用。';
if (ajax_request()) {
$card = '';
$market = self::plugin_market_fetch(false, false, '', 0, $market_view);
$item = is_array($market['plugins'][$plugin_id] ?? null) ? $market['plugins'][$plugin_id] : null;
if ($item) {
$local = self::plugin_registry(null, true);
$needs_update = isset($local[$plugin_id]) && self::plugin_market_update_available($local[$plugin_id], $item);
$card = self::admin_plugin_market_card_html($item, $needs_update, $market_view, $local);
}
$payload = ['ok' => 1, 'message' => $message];
if ($card !== '') $payload['card'] = $card;
else $payload['refresh'] = 1;
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
set_flash($message);
go(admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => $return_view]));
}
public static function plugin_market_share_page(): void
{
need_admin();
require_post();
$id = (string)($_POST['plugin_id'] ?? '');
$target = self::plugin_id_valid($id) ? (self::plugin_registry($id)[$id] ?? null) : null;
if (!is_array($target)) err('插件不存在');
$file = (string)($target['file'] ?? '');
$code = $file !== '' && is_file($file) ? file_get_contents($file) : false;
if (!is_string($code) || trim($code) === '') err('插件文件不存在或为空');
if (preg_match('/^\s*```\s*[\w-]*\s*$/m', $code)) err('插件代码包含独立的 Markdown 代码块标记,无法安全分享');
$body = "```php\n" . rtrim($code) . "\n```";
$name = trim((string)($target['name'] ?? '')) ?: $id;
$share_form = '';
$author = trim((string)($target['author'] ?? ''));
$head = '正在前往插件市场插件代码已准备好,请在官方站点确认后发布。
';
$row = '' . h($name) . '
' . $share_form . '
ID ' . h($id) . '分享' . ((string)($target['version'] ?? '') !== '' ? '版本 ' . h((string)$target['version']) . '' : '') . ($author !== '' ? '插件作者 ' . h($author) . '' : '') . '
' . self::plugin_desc_html((string)($target['description'] ?? '')) . '
' . h($file) . '
';
page('分享插件', shell_html('' . admin_list_head($head, '') . '
', sidebar_stack_html([sidebar_user_card_html()])));
}
public static function plugin_download_page(): void
{
need_admin();
require_post();
$id = (string)($_POST['plugin_id'] ?? '');
$plugin = self::plugin_id_valid($id) ? (self::plugin_registry($id)[$id] ?? null) : null;
if (!is_array($plugin)) err('插件不存在');
$file = (string)($plugin['file'] ?? '');
$expected = rtrim(str_replace('\\', '/', PLUGIN_DIR), '/') . '/' . $id . '/plugin.php';
if (str_replace('\\', '/', $file) !== $expected || is_link($file) || !is_file($file) || !is_readable($file)) err('插件文件不存在或不可读');
$code = file_get_contents($file);
if (!is_string($code) || $code === '') err('插件文件不存在或为空');
$version = trim((string)preg_replace('/[^A-Za-z0-9._-]+/', '_', (string)($plugin['version'] ?? '')), '._-');
if ($version === '') $version = 'unknown';
$filename = $id . '_' . $version . '.php1';
header('Content-Type: application/octet-stream');
header('Content-Length: ' . strlen($code));
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('X-Content-Type-Options: nosniff');
echo $code;
exit;
}
public static function plugin_market_update_available(array $plugin, array $item): bool
{
$remote = trim((string)($item['version'] ?? ''));
$local = trim((string)($plugin['version'] ?? ''));
return $remote !== '' && $local !== '' && version_compare($remote, $local, '>');
}
public static function plugin_market_search_form(string $query, string $market_view): string
{
$view_param = self::plugin_market_view_param($market_view);
$hidden = hidden_inputs(['a' => 'admin', 'tab' => 'plugins', 'view' => 'market', 'market_view' => (string)$view_param]);
$url = admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => $view_param]);
$clear = $query !== '' ? '清空' : '';
return '';
}
public static function plugin_market_matches(array $item, string $query): bool
{
if ($query === '') return true;
foreach (['title', 'name', 'id', 'creator', 'description'] as $key) if (stripos((string)($item[$key] ?? ''), $query) !== false) return true;
return false;
}
public static function plugin_market_page_html(bool $with_tabs = true): string
{
$market_view = (string)($_GET['market_view'] ?? '');
$market_view = $market_view === 'beta' ? 'beta' : ($market_view === 'required' ? 'required' : 'certified');
$feed_view = $market_view === 'beta' ? 'beta' : 'certified';
$force = (string)($_GET['refresh'] ?? '') === '1';
$query = trim((string)($_GET['q'] ?? ''));
$market = self::plugin_market_fetch(false, $force, '', 0, $feed_view);
$items = is_array($market['plugins'] ?? null) ? $market['plugins'] : [];
if ($query !== '') {
$other_market = self::plugin_market_fetch(false, $force, '', 0, $feed_view === 'beta' ? 'certified' : 'beta');
$merged = array_merge(array_values($items), array_values(is_array($other_market['plugins'] ?? null) ? $other_market['plugins'] : []));
$items = [];
foreach ($merged as $item) {
if (!is_array($item)) continue;
$key = (string)($item['id'] ?? '') . ':' . (int)($item['topic_id'] ?? 0) . ':' . (string)($item['market_status'] ?? 'certified');
$items[$key] = $item;
}
if (empty($market['ok']) && !empty($other_market['ok'])) $market = $other_market;
}
$local = self::plugin_registry();
$updates = [];
foreach ($items as $item) {
if (!is_array($item)) continue;
$id = (string)($item['id'] ?? '');
if (isset($local[$id]) && self::plugin_market_update_available($local[$id], $item)) $updates[$id] = true;
}
$items = array_filter($items, static function ($item) use ($query, $market_view, $feed_view): bool {
if (!is_array($item)) return false;
$id = (string)($item['id'] ?? '');
if (!self::plugin_id_valid($id)) return false;
if ($market_view === 'required' && empty($item['required'])) return false;
return ($query !== '' || (string)($item['market_status'] ?? 'certified') === $feed_view) && self::plugin_market_matches($item, $query);
});
uasort($items, static function (array $a, array $b) use ($updates, $local): int {
$a_id = (string)($a['id'] ?? '');
$b_id = (string)($b['id'] ?? '');
$update = (int)isset($updates[$b_id]) <=> (int)isset($updates[$a_id]);
if ($update !== 0) return $update;
$required = static fn(array $item): int => !empty($item['required']) && !isset($local[(string)($item['id'] ?? '')]) ? 1 : 0;
$required_sort = $required($b) <=> $required($a);
if ($required_sort !== 0) return $required_sort;
$updated = (int)($b['updated_at'] ?? 0) <=> (int)($a['updated_at'] ?? 0);
if ($updated !== 0) return $updated;
$topic = (int)($b['topic_id'] ?? 0) <=> (int)($a['topic_id'] ?? 0);
return $topic !== 0 ? $topic : strcmp($a_id, $b_id);
});
$total = count($items);
$page = max(1, (int)($_GET['p'] ?? 1));
$pages = min(max_pagination_pages(), max(1, (int)ceil($total / PLUGIN_MARKET_PAGE_SIZE)));
$page = min($page, $pages);
$items = array_slice($items, ($page - 1) * PLUGIN_MARKET_PAGE_SIZE, PLUGIN_MARKET_PAGE_SIZE, true);
$view_param = self::plugin_market_view_param($market_view);
$url = admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => $view_param]);
$head = '插件市场免费插件可直接在线安装;付费插件购买后,在插件页面获取一次性授权码即可在线安装或更新。' . ((int)($market['fetched_at'] ?? 0) > 0 ? '
列表更新于 ' . date('Y-m-d H:i', (int)$market['fetched_at']) . '。' : '') . '
';
$actions = '' . self::plugin_market_search_form($query, $market_view) . '
刷新 ';
$market_tabs = tab_bar_html([
'certified' => ['label' => '站长认证', 'href' => admin_url(['tab' => 'plugins', 'view' => 'market'])],
'required' => ['label' => '必装', 'href' => admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => 'required'])],
'beta' => ['label' => '最新beta', 'href' => admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => 'beta'])],
], $market_view, 'admin-tabs');
$html = ($with_tabs ? self::admin_plugins_tabs_html('market') : '') . '' . admin_list_head($head, $actions) . $market_tabs . '
';
if (!(int)($market['ok'] ?? 0)) return $html . '- ' . h((string)($market['message'] ?? '插件市场暂不可用')) . '
';
foreach ($items as $item) {
$id = (string)($item['id'] ?? '');
$html .= self::admin_plugin_market_card_html($item, isset($updates[$id]), $market_view, $local);
}
if (!$items) $html .= '' . h($query !== '' ? '没有匹配的插件。' : ($market_view === 'beta' ? '暂无待审核或忽略的插件。' : ($market_view === 'required' ? '暂无必装插件。' : '暂无已审核通过的插件。'))) . '';
$html .= '';
$pagination = paginate($total, $page, PLUGIN_MARKET_PAGE_SIZE, admin_url(['tab' => 'plugins', 'view' => 'market', 'market_view' => $view_param, 'q' => $query !== '' ? $query : null]));
return $html . ($pagination !== '' ? '' : '');
}
public static function admin_plugin_market_card_html(array $item, bool $needs_update, string $market_view, array $local): string
{
$id = (string)($item['id'] ?? '');
$installed = isset($local[$id]);
$price_points = max(0, (int)($item['price_points'] ?? 0));
$online_install = !array_key_exists('online_install', $item) ? $price_points === 0 : !empty($item['online_install']);
$paid = $price_points > 0;
$item_market_view = self::plugin_market_view((string)($item['market_status'] ?? 'certified'));
$label = $installed ? ($needs_update ? '更新' : '重新安装') : '安装';
$button_class = $installed && !$needs_update ? '' : 'plugin-enable';
$topic_url = (string)($item['url'] ?? '');
if ($paid) {
$online_label = $installed ? ($needs_update ? '在线更新' : '在线重装') : '在线安装';
$online_form = '';
$ops = $online_form;
} elseif ($online_install) {
$ops = '';
} else {
$ops = $topic_url !== '' ? '下载' : '';
}
$extra = ['card_scope' => 'market', 'market_view' => $market_view];
$local_ops = $installed ? self::admin_plugin_local_actions_html($local[$id], $extra) : '';
$entry_line = $installed ? self::admin_plugin_entry_line_html($local[$id], $extra) : '';
$topic_link = $topic_url !== '' ? '插件主题' : '';
$ops .= $local_ops . $topic_link;
$meta = [];
if ((string)($item['version'] ?? '') !== '') $meta[] = '版本 ' . (string)$item['version'];
$creator = trim((string)($item['creator'] ?? ''));
$meta[] = '插件制作者 ' . ($creator !== '' ? $creator : '未声明');
if (!$paid) $meta[] = '免费';
if ((int)($item['updated_at'] ?? 0) > 0) $meta[] = date('Y-m-d H:i', (int)$item['updated_at']);
$title = h((string)($item['name'] ?? $id));
$title = $topic_url !== '' ? '' . $title . '' : '' . $title . '';
$ondemand = self::plugin_assets_ondemand_any(['assets_mode' => $item['assets_mode'] ?? '']) || ($installed && self::plugin_assets_ondemand_any($local[$id] ?? []));
$ondemand_mark = $ondemand ? self::plugin_ondemand_mark_html() : '';
$status = [];
if ($item_market_view === 'certified') $status[] = '站长认证';
if (!empty($item['required'])) $status[] = '必装';
$status[] = $installed ? ($needs_update ? '可更新' : '已安装') : '未安装';
$price_tag = $paid ? '' . $price_points . ' 积分' : '';
$required_stamp = !empty($item['required']) ? '必装' : '';
$class = ' plugin-market-entry' . ($needs_update ? ' plugin-market-update plugin-update-item' : ($installed ? ' plugin-market-installed' : ' plugin-market-available')) . ($required_stamp !== '' ? ' plugin-has-required' : '');
return '' . $required_stamp . '' . $title . $ondemand_mark . $price_tag . '
' . $ops . '
ID ' . h($id) . '' . h(implode(' / ', $status)) . '' . h(implode(' / ', $meta)) . '
' . self::plugin_desc_html((string)($item['description'] ?? '')) . '
' . h(substr((string)($item['sha256'] ?? ''), 0, 16)) . '
' . $entry_line . '';
}
public static function plugin_market_admin_actions(array $plugin, array $extra = []): string
{
$id = (string)($plugin['id'] ?? '');
if (!self::plugin_id_valid($id)) return '';
$share = '';
$download = '';
return $share . $download;
}
private static function plugin_ondemand_mark_html(): string
{
return '';
}
public static function plugin_desc_html(string $desc): string
{
$desc = trim($desc);
if ($desc === '') return '';
return '';
}
public static function admin_plugin_action_form(string $id, string $action, string $label, string $class = '', string $confirm = '', array $extra = []): string
{
$confirm_attr = $confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '';
$loading_attr = $action === 'enable' ? ' data-loading-text="正在启用"' : '';
return '';
}
public static function admin_plugin_upload_form(): string
{
$form = '';
return '' . $form . '';
}
public static function admin_plugin_uninstall_form(string $id, array $extra = []): string
{
return '';
}
public static function admin_plugin_entry_toggle_form(array $plugin, string $entry, string $label, array $extra = []): string
{
if (!self::plugin_uses_entry($plugin, $entry)) return '';
$id = (string)$plugin['id'];
$checked = self::plugin_entry_enabled($plugin, $entry);
return '';
}
public static function admin_plugin_entry_line_html(array $plugin, array $extra = []): string
{
$entry_ops = '';
foreach (self::plugin_entry_definitions() as $entry => $definition) {
$entry_ops .= self::admin_plugin_entry_toggle_form($plugin, $entry, (string)$definition['label'], $extra);
}
return $entry_ops !== '' ? '' : '';
}
public static function admin_plugin_local_actions_html(array $plugin, array $extra = []): string
{
$id = (string)$plugin['id'];
$manage_url = self::admin_plugin_manage_url($plugin);
$ops = $manage_url !== '' ? '管理' : '';
$market_topic_id = max(0, (int)setting('plugin_' . $id . '_market_topic_id'));
$feedback = '';
if ($market_topic_id > 0) {
$feedback_url = append_url_query(self::plugin_market_url('topic'), ['id' => $market_topic_id]);
$feedback = '反馈';
}
$ops .= self::plugin_enabled($plugin)
? self::admin_plugin_action_form($id, 'disable', '停用', 'danger', '确定停用插件?', $extra)
: self::admin_plugin_action_form($id, 'enable', '启用', 'plugin-enable', '', $extra);
$ops .= self::plugin_market_admin_actions($plugin, $extra);
$ops .= (string)hook('admin.plugin.actions', '', ['plugin' => $plugin]);
$ops .= self::admin_plugin_uninstall_form($id, $extra);
$ops .= $feedback;
return $ops;
}
public static function admin_plugins_page_html(bool $with_tabs = true): string
{
$plugins = self::plugin_registry();
uasort($plugins, function (array $a, array $b): int {
$a_time = (int)($a['updated_at'] ?? 0);
$b_time = (int)($b['updated_at'] ?? 0);
return ($b_time <=> $a_time) ?: strcmp((string)($a['id'] ?? ''), (string)($b['id'] ?? ''));
});
$head_left = self::admin_plugin_summary_html($plugins);
$search = '';
$head_right = '' . $search . self::admin_plugin_upload_form() . self::admin_plugin_action_form('', 'sync', '同步插件', 'plugin-head-button') . '
';
$html = ($with_tabs ? self::admin_plugins_tabs_html('local', $plugins) : '') . '' . admin_list_head($head_left, $head_right) . '
';
foreach ($plugins as $plugin) $html .= self::admin_plugin_local_card_html($plugin);
if ($plugins) $html .= '- 未找到匹配插件
';
else $html .= '- 暂无插件,放入 app/plugins/*/plugin.php 后点击“同步插件”。
';
return $html . '
';
}
public static function admin_plugin_summary_html(?array $plugins = null): string
{
$plugins ??= self::plugin_registry();
$enabled_count = 0;
foreach ($plugins as $plugin) if (self::plugin_enabled($plugin)) $enabled_count++;
return '插件已发现 ' . count($plugins) . ' 个,已启用 ' . $enabled_count . ' 个
';
}
public static function admin_plugin_local_card_html(array $plugin): string
{
$id = (string)$plugin['id'];
$enabled = self::plugin_enabled($plugin);
$manage_url = self::admin_plugin_manage_url($plugin);
$ops = self::admin_plugin_local_actions_html($plugin);
$entry_line = self::admin_plugin_entry_line_html($plugin);
$meta = [];
if ((string)($plugin['version'] ?? '') !== '') $meta[] = '版本 ' . (string)$plugin['version'];
if ((string)($plugin['author'] ?? '') !== '') $meta[] = (string)$plugin['author'];
$features = [];
if (!empty($plugin['hooks'])) $features[] = count($plugin['hooks']) . ' 个钩子';
if (!empty($plugin['routes'])) $features[] = count($plugin['routes']) . ' 个路由';
if (!empty($plugin['admin_tabs'])) $features[] = count($plugin['admin_tabs']) . ' 个后台页';
$file = str_replace(APP_ROOT . '/', '', (string)($plugin['file'] ?? ''));
$disabled_reason = !$enabled ? trim((string)($plugin['disabled_reason'] ?? '')) : '';
$reason_line = $disabled_reason !== '' ? '自动停用原因' . h($disabled_reason) . '
' : '';
$local_class = $enabled ? ' plugin-local-enabled' : ' plugin-local-disabled';
$title = $manage_url !== '' ? '' . h((string)$plugin['name']) . '' : '' . h((string)$plugin['name']) . '';
$ondemand_mark = self::plugin_assets_ondemand_any($plugin) ? self::plugin_ondemand_mark_html() : '';
$search_text = implode("\n", [(string)($plugin['name'] ?? ''), $id, (string)($plugin['author'] ?? ''), (string)($plugin['description'] ?? '')]);
return '' . $title . $ondemand_mark . '
' . $ops . '
ID ' . h($id) . '' . h($enabled ? '已启用' : '已停用') . '' . ($meta ? '' . h(implode(' / ', $meta)) . '' : '') . ($features ? '' . h(implode(' / ', $features)) . '' : '') . '
' . self::plugin_desc_html((string)($plugin['description'] ?? '')) . '
' . h($file) . '
' . $reason_line . '
' . $entry_line . '';
}
public static function admin_plugin_manage_url(array $plugin): string
{
if (!self::plugin_enabled($plugin) || !is_array($plugin['admin_tabs'] ?? null)) return '';
foreach ($plugin['admin_tabs'] as $key => $fn) {
if (is_string($key) && $key !== '' && is_string($fn) && $fn !== '') return admin_url(['tab' => $key]);
}
return '';
}
public static function admin_plugins_tabs_html(string $active, ?array $plugins = null): string
{
$items = [
'local' => ['label' => '本地插件', 'href' => admin_url(['tab' => 'plugins'])],
'market' => ['label' => '插件市场', 'href' => admin_url(['tab' => 'plugins', 'view' => 'market'])],
];
$hook_items = hook('admin.plugins.tabs', $items, ['active' => $active]);
if (is_array($hook_items)) $items = $hook_items;
$items['cron'] = ['label' => '计划任务日志', 'href' => admin_url(['tab' => 'plugins', 'view' => 'cron'])];
$entry_html = '';
if ($active === 'local') {
$entries = [];
foreach (($plugins ?? self::plugins()) as $plugin) {
$url = self::admin_plugin_manage_url($plugin);
if ($url !== '') $entries[] = '' . h((string)($plugin['name'] ?? $plugin['id'])) . '';
}
if ($entries) $entry_html = '插件管理配置' . implode('', $entries) . '
';
}
return tab_bar_html($items, $active, 'plugin-tabs', 'admin.plugins.tabs') . $entry_html;
}
public static function admin_plugins_cron_logs_page_html(): string
{
$size = 50;
$page = max(1, (int)($_GET['p'] ?? 1));
$offset = ($page - 1) * $size;
$names = [];
foreach (self::plugin_registry() as $plugin) {
$names[(string)$plugin['id']] = (string)($plugin['name'] ?? $plugin['id']);
}
$rows = q("SELECT plugin_id,task_name,status,message,started_at,finished_at FROM app_cron_logs ORDER BY started_at DESC,id DESC LIMIT ? OFFSET ?", [$size + 1, $offset])->fetchAll();
$has_next = count($rows) > $size;
if ($has_next) array_pop($rows);
$labels = ['success' => '成功', 'failed' => '失败', 'running' => '运行中'];
$html = self::admin_plugins_tabs_html('cron') . '' . admin_list_head('
计划任务日志最新运行记录
', '') . '
';
foreach ($rows as $row) {
$status = (string)$row['status'];
$class = $status === 'success' ? ' on' : ($status === 'failed' ? ' danger' : '');
$started_at = (int)$row['started_at'];
$finished_at = (int)$row['finished_at'];
$duration = $finished_at > 0 ? max(0, $finished_at - $started_at) . ' 秒' : '进行中';
$message = trim((string)$row['message']);
$plugin_id = (string)$row['plugin_id'];
$plugin_name = $names[$plugin_id] ?? $plugin_id;
$html .= '' . h($plugin_name) . '' . h($labels[$status] ?? $status) . '
' . h($plugin_id) . ' / ' . h((string)$row['task_name']) . '' . date('Y-m-d H:i:s', $started_at) . '' . h($duration) . '' . ($message !== '' ? '' . h($message) . '' : '') . '
';
}
if (!$rows) $html .= '- 暂无计划任务运行记录
';
$html .= '
';
$pagination = simple_paginate($page > 1, $has_next, $page, admin_url(['tab' => 'plugins', 'view' => 'cron']));
return $html . ($pagination === '' ? '' : '');
}
public static function admin_plugin_tab_html(string $tab): ?string
{
foreach (self::plugins() as $plugin) {
if (!self::plugin_enabled($plugin)) continue;
$fn = $plugin['admin_tabs'][$tab] ?? null;
if ($fn !== null) {
$html = self::plugin_call($plugin, fn(): ?string => self::plugin_callback_exists($fn) ? (string)$fn($plugin) : null);
if ($html !== null) return self::admin_plugin_page_html($plugin, $html);
}
}
return null;
}
private static function admin_plugin_page_html(array $plugin, string $html): string
{
$id = (string)($plugin['id'] ?? 'plugin');
$page_tabs = '';
if (preg_match('/^\s*]*>.*?<\/div>\s*/s', $html, $match)) {
$page_tabs = trim($match[0]);
$html = substr($html, strlen($match[0]));
}
if (!str_contains($html, 'admin-list-panel')) {
$registered = $id !== '' ? (self::plugin_registry($id)[$id] ?? []) : [];
$name = trim((string)($registered['name'] ?? $plugin['name'] ?? '')) ?: $id;
$description = trim((string)($registered['description'] ?? $plugin['description'] ?? ''));
$head = '
' . h($name) . ''
. ($description !== '' ? '' . h($description) . '' : '') . '
';
$html = '
'
. admin_list_head($head, '') . '' . $html . '
';
}
return $page_tabs . '
' . $html . '
';
}
public static function plugin_disable_after_exception(string $id, Throwable $e): void
{
if (!self::plugin_id_valid($id)) return;
if (self::plugin_infra_exception($e)) {
debug_log_write('插件 ' . $id . ' 遇到数据库连接异常,未自动停用', $e);
return;
}
static $handled;
$handled ??= new WeakMap();
if (isset($handled[$e])) return;
$handled[$e] = true;
$message = trim((string)preg_replace('/\s+/', ' ', $e->getMessage()));
$file = str_replace('\\', '/', $e->getFile());
$root = rtrim(str_replace('\\', '/', APP_ROOT), '/') . '/';
if (str_starts_with($file, $root)) $file = substr($file, strlen($root));
$reason = date('Y-m-d H:i:s') . ' ' . get_class($e) . ($message !== '' ? ': ' . $message : '') . ($file !== '' ? ' (' . $file . ':' . $e->getLine() . ')' : '');
try {
self::plugin_update_row($id, ['enabled' => 0, 'status' => 'error', 'disabled_reason' => $reason]);
q("UPDATE app_cron_tasks SET enabled=0 WHERE plugin_id=?", [$id]);
self::plugins(true);
self::plugin_registry_flush();
self::plugin_assets_mark_dirty();
} catch (Throwable $disable_error) {
debug_log_write('插件 ' . $id . ' 自动停用失败', $disable_error);
debug_log_write('插件 ' . $id . ' 运行异常', $e);
return;
}
debug_log_write('插件 ' . $id . ' 运行异常,已自动停用', $e);
}
private static function plugin_infra_exception(Throwable $e): bool
{
if (!$e instanceof \PDOException) return false;
$code = (int)($e->errorInfo[1] ?? 0);
if (in_array($code, [2006, 2013], true)) return true;
$state = (string)($e->errorInfo[0] ?? $e->getCode());
if (str_starts_with($state, '08')) return true;
$message = $e->getMessage();
foreach (['server has gone away', 'Lost connection', 'no connection to the server', 'server closed the connection'] as $needle) {
if (stripos($message, $needle) !== false) return true;
}
return false;
}
public static function plugin_manifest_validate(array $plugin, string $file): ?array
{
$id = (string)($plugin['id'] ?? '');
if (!self::plugin_id_valid($id) || $id !== basename(dirname($file))) return null;
$base = [
'id' => $id,
'name' => (string)($plugin['name'] ?? $id),
'version' => (string)($plugin['version'] ?? ''),
'description' => (string)($plugin['description'] ?? ''),
'author' => (string)($plugin['author'] ?? ''),
'enabled' => !empty($plugin['enabled']),
'hooks' => is_array($plugin['hooks'] ?? null) ? $plugin['hooks'] : [],
'links' => is_array($plugin['links'] ?? null) ? $plugin['links'] : [],
'routes' => is_array($plugin['routes'] ?? null) ? $plugin['routes'] : [],
'admin_tabs' => is_array($plugin['admin_tabs'] ?? null) ? $plugin['admin_tabs'] : [],
'assets' => is_array($plugin['assets'] ?? null) ? $plugin['assets'] : [],
'assets_mode' => self::plugin_assets_mode_normalize($plugin['assets_mode'] ?? ''),
'cron' => is_array($plugin['cron'] ?? null) ? $plugin['cron'] : [],
'entries' => is_array($plugin['entries'] ?? null) ? $plugin['entries'] : [],
'install' => (string)($plugin['install'] ?? ''),
'uninstall' => (string)($plugin['uninstall'] ?? ''),
'file' => $file,
];
foreach (['hooks', 'routes', 'admin_tabs'] as $map) {
$items = [];
foreach ($base[$map] as $name => $fn) if (is_string($name) && is_string($fn) && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $fn)) $items[$name] = $fn;
$base[$map] = $items;
}
$base['links'] = self::plugin_links_normalize($base['links']);
$entries = [];
foreach ($base['entries'] as $entry => $enabled) {
$entry = (string)$entry;
$hook = self::plugin_entry_hook_name($entry);
if ($hook !== '' && (isset($base['hooks'][$hook]) || !empty($base['links'][$entry]))) $entries[$entry] = !empty($enabled);
}
$base['entries'] = $entries;
$assets = [];
foreach (['css', 'js'] as $type) {
$fn = $base['assets'][$type] ?? null;
if (is_string($fn) && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $fn)) $assets[$type] = $fn;
}
$base['assets'] = $assets;
$cron = [];
foreach ($base['cron'] as $name => $task) {
if (!is_string($name) || preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $name) !== 1 || !is_array($task)) continue;
$callback = (string)($task['callback'] ?? '');
$interval = $task['interval'] ?? 0;
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $callback) !== 1) continue;
if (is_string($interval) && !is_numeric($interval)) {
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $interval) !== 1) continue;
} else {
$interval = (int)$interval;
if ($interval < 60) continue;
$interval = min(31536000, $interval);
}
$cron[$name] = ['callback' => $callback, 'interval' => $interval];
}
$base['cron'] = $cron;
foreach (['install', 'uninstall'] as $key) if ($base[$key] !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $base[$key]) !== 1) $base[$key] = '';
return $base;
}
private static function plugin_link_ctx_keys(): array
{
return ['uid', 'self', 'active', 'forum_id', 'query', 'page', 'user.id', 'user.username'];
}
private static function plugin_link_url_valid(string $url): bool
{
if ($url === '' || preg_match('/[\x00-\x1f\x7f]/', $url) === 1) return false;
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) === 1 && preg_match('/^https?:/i', $url) !== 1) return false;
return true;
}
private static function plugin_link_icon_html(string $html): string
{
$html = trim($html);
if ($html === '' || strlen($html) > 1600) return '';
if (str_starts_with($html, '