$module], $params)); } /** * Every scoped account gets Dashboard and Search for free, whatever else it * was granted - otherwise an account limited to, say, just "news" would land * on an access-denied wall the moment it logs in, since dashboard is the * default page. */ function acp_module_free_for_any_role(): array { return array('dashboard', 'search'); } function acp_can_module(string $module, ?array $account = null): bool { $account = $account ?? ($GLOBALS['user_data'] ?? null); $roles = admin_roles($account); if (in_array('owner', $roles, true)) return true; if ($roles === array()) return false; if (in_array($module, acp_module_free_for_any_role(), true)) return true; return in_array(strtolower($module), $roles, true); } /** Literal Owner only - never satisfied by a granted module, even 'settings'. */ function acp_is_owner(?array $account = null): bool { $account = $account ?? ($GLOBALS['user_data'] ?? null); return in_array('owner', admin_roles($account), true); } function acp_editor_assets(): void { static $loaded = false; if ($loaded) return; $loaded = true; $v = '3.2.1'; $base = '../assets/sceditor/'; ?>

> 10, 'Content' => 20, 'Players' => 30, 'Server Info' => 40, 'Economy' => 50, 'Support' => 60, 'Operations' => 65, 'Settings' => 70, ]; function acp_parse_module_header(string $file): array { $head = (string)file_get_contents($file, false, null, 0, 1500); $meta = []; $manifestFile = substr($file, 0, -4) . '.json'; if (is_file($manifestFile)) { $manifest = json_decode((string)file_get_contents($manifestFile), true); if (is_array($manifest)) { foreach ($manifest as $key => $value) { if (is_string($key) && (is_string($value) || is_numeric($value) || is_bool($value))) { $meta[strtolower($key)] = $value; } } } } if (preg_match('~/\*\*(.*?)\*/~s', $head, $block)) { foreach (preg_split('~\R~', $block[1]) as $line) { if (preg_match('~^\s*\*\s*([A-Za-z]+)\s*:\s*(.+?)\s*$~', $line, $kv)) { $meta[strtolower($kv[1])] = $kv[2]; } } } return $meta; } /** Every admin/modules/*.php file, parsed into a nav entry each. */ function acp_modules_core(): array { $modules = []; foreach (glob(ACP_ROOT . '/modules/*.php') ?: [] as $file) { $key = basename($file, '.php'); if ($key === '' || $key[0] === '_') { continue; } $meta = acp_parse_module_header($file); $group = $meta['group'] ?? 'Other'; $modules[$key] = [ 'key' => $key, 'file' => $file, 'title' => t_default('acp.mod.' . $key . '.title', $meta['title'] ?? ucwords(str_replace('_', ' ', $key))), 'icon' => $meta['icon'] ?? 'fa-square-o', 'group' => t_default('acp.group.' . $group, $group), 'order' => isset($meta['order']) ? (int)$meta['order'] : 100, 'description' => t_default('acp.mod.' . $key . '.desc', $meta['description'] ?? ''), 'url' => $meta['url'] ?? null, 'target' => $meta['target'] ?? null, 'hidden' => !empty($meta['hidden']) && strtolower((string)$meta['hidden']) !== 'false', ]; } return $modules; } /** * Modules contributed by enabled plugins, listed beside the built-in ones. * Their key is prefixed with the plugin name, so a plugin can never shadow a * core module by choosing the same filename. */ function acp_modules_from_plugins(): array { $modules = []; if (function_exists('znote_plugin_admin_modules')) { foreach (znote_plugin_admin_modules() as $key => $file) { $meta = acp_parse_module_header($file); $modules[$key] = array( 'key' => $key, 'file' => $file, 'title' => $meta['title'] ?? ucwords(str_replace('_', ' ', $key)), 'icon' => $meta['icon'] ?? 'fa-plug', 'group' => $meta['group'] ?? 'Settings', 'order' => isset($meta['order']) ? (int)$meta['order'] : 100, 'description' => $meta['description'] ?? '', 'url' => $meta['url'] ?? null, 'target' => $meta['target'] ?? null, ); } } return $modules; } /** * A plugin's generic settings.json config used to only be reachable from its * row on the Plugins list page, off on its own away from the same plugin's * custom admin pages (Slots, Storage Watch, ...) that already show up under * "Installed Plugins". Listing it right there too - same group, same place - * means everything about one plugin lives in one spot instead of two. * $existingKeys is what acp_modules_core()/acp_modules_from_plugins() already * produced, so a plugin that names its own page "settings" is never clobbered. */ function acp_modules_plugin_settings(array $existingKeys): array { $modules = []; if (function_exists('znote_plugins') && function_exists('znote_plugin_settings_has')) { foreach (znote_plugins() as $name => $plugin) { if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible'] || !znote_plugin_settings_has($name)) { continue; } $key = $name . '__settings'; if (isset($existingKeys[$key])) { continue; } $modules[$key] = array( 'key' => $key, 'file' => ACP_ROOT . '/modules/plugin_settings.php', 'title' => (string)($plugin['name'] ?? ucwords(str_replace('_', ' ', $name))) . ' Settings', 'icon' => 'fa-sliders', 'group' => 'Installed Plugins', 'order' => 5, 'description' => t_default('acp.mod.plugin_settings.desc', "This plugin's own configuration fields."), 'url' => acp_url('plugin_settings', array('plugin' => $name)), 'target' => null, ); } } return $modules; } function acp_modules(): array { static $modules = null; if ($modules !== null) { return $modules; } $modules = acp_modules_core() + acp_modules_from_plugins(); $modules += acp_modules_plugin_settings($modules); uasort($modules, static function (array $a, array $b): int { $ga = ACP_GROUP_ORDER[$a['group']] ?? 900; $gb = ACP_GROUP_ORDER[$b['group']] ?? 900; return [$ga, $a['order'], strtolower($a['title'])] <=> [$gb, $b['order'], strtolower($b['title'])]; }); return $modules; } function acp_nav_groups(): array { $groups = []; foreach (acp_modules() as $key => $module) { if (!empty($module['hidden']) || !acp_can_module($key)) { continue; } $groups[$module['group']][$key] = $module; } return $groups; } /** * Splits one group's modules into standalone links and per-plugin clusters, * so a sidebar group with several plugins each contributing 2-3 pages shows * one row per plugin (expanding to its pages) instead of every page flattened * into the group - the more plugins are installed, the more this matters. * * A plugin-contributed key is always "__" (see * znote_plugin_admin_modules() and the settings.json injection above); core * modules never contain "__", so they always end up in 'plain'. */ function acp_nav_cluster(array $groupModules): array { $plain = []; $plugins = []; foreach ($groupModules as $key => $module) { $sep = strpos($key, '__'); if ($sep === false) { $plain[$key] = $module; continue; } $pluginKey = substr($key, 0, $sep); if (!isset($plugins[$pluginKey])) { $manifest = function_exists('znote_plugins') ? (znote_plugins()[$pluginKey] ?? null) : null; $plugins[$pluginKey] = [ 'label' => (string)($manifest['name'] ?? ucwords(str_replace('_', ' ', $pluginKey))), 'items' => [], ]; } $plugins[$pluginKey]['items'][$key] = $module; } // A plugin with only one page gains nothing from being nested - it is // the same click count either way, just with an extra level in between - // so it goes back to a plain, top-level link instead. foreach ($plugins as $pluginKey => $cluster) { if (count($cluster['items']) === 1) { $plain += $cluster['items']; unset($plugins[$pluginKey]); } } // "Build Simulator Slots" under the "Build Simulator" cluster reads // better as just "Slots" - only strips the prefix when it is actually // there, so a plugin with differently-worded titles is untouched. foreach ($plugins as $pluginKey => &$cluster) { $prefix = $cluster['label'] . ' '; foreach ($cluster['items'] as &$module) { if (stripos($module['title'], $prefix) === 0) { $module['title'] = substr($module['title'], strlen($prefix)); } } unset($module); } unset($cluster); return ['plain' => $plain, 'plugins' => $plugins]; } /** * Everything the top-bar search can find: the modules themselves and every * field they expose. The point is that "download" reaches the client URL * fields under Settings, not just a module whose title happens to match. * * The schemas are read from _partials/, which only return arrays - including * the module itself would run its POST handling and print its page. */ function acp_search_index_modules(): array { $entries = []; foreach (acp_modules() as $key => $module) { if (!empty($module['hidden']) || !acp_can_module($key)) { continue; } $entries[] = [ 'kind' => 'page', 'title' => $module['title'], 'context' => $module['group'], 'detail' => $module['description'], 'url' => $module['url'] ?: acp_url($key), 'icon' => $module['icon'], 'haystack' => strtolower($key . ' ' . $module['title'] . ' ' . $module['group'] . ' ' . $module['description']), ]; } return $entries; } function acp_search_index_settings(): array { $entries = []; $settings = ACP_ROOT . '/modules/_partials/settings_schema.php'; if (acp_can_module('settings') && is_file($settings)) { foreach ((array)require $settings as $section => $fields) { foreach ((array)$fields as $key => $field) { $entries[] = [ 'kind' => 'setting', 'title' => $field['label'] ?? $key, 'context' => 'Settings › ' . $section, 'detail' => $field['help'] ?? '', 'url' => acp_url('settings') . '#set_' . $key, 'icon' => 'fa-cogs', 'haystack' => strtolower($key . ' ' . ($field['label'] ?? '') . ' ' . ($field['help'] ?? '') . ' ' . $section), ]; } } } return $entries; } function acp_search_index_payments(): array { $entries = []; $payments = ACP_ROOT . '/modules/_partials/payments_schema.php'; if (acp_can_module('payments') && is_file($payments)) { foreach ((array)require $payments as $groupName => $group) { foreach ((array)($group['fields'] ?? []) as $key => $field) { $entries[] = [ 'kind' => 'setting', 'title' => $field['label'] ?? $key, 'context' => 'Payments › ' . $groupName, 'detail' => $field['help'] ?? '', 'url' => acp_url('payments') . '#pay_' . $key, 'icon' => 'fa-credit-card', 'haystack' => strtolower($key . ' ' . ($field['label'] ?? '') . ' ' . ($field['help'] ?? '') . ' ' . $groupName), ]; } } } return $entries; } function acp_search_index_layouts(): array { $entries = []; if (acp_can_module('layouts') && function_exists('theme_list') && function_exists('theme_options')) { foreach (theme_list() as $themeKey => $theme) { foreach (theme_options($themeKey) as $optKey => $opt) { $entries[] = [ 'kind' => 'setting', 'title' => $opt['label'] ?? $optKey, 'context' => 'Layout › ' . ($theme['name'] ?? $themeKey) . ' options', 'detail' => $opt['help'] ?? '', 'url' => acp_url('layouts', ['options' => $themeKey]), 'icon' => 'fa-paint-brush', 'haystack' => strtolower($optKey . ' ' . ($opt['label'] ?? '') . ' ' . ($opt['help'] ?? '') . ' ' . $themeKey), ]; } } } return $entries; } function acp_search_index(): array { static $index = null; if ($index !== null) { return $index; } $index = array_merge( acp_search_index_modules(), acp_search_index_settings(), acp_search_index_payments(), acp_search_index_layouts() ); return $index; } /** Rank matches: whole-word and title hits first. */ function acp_search(string $query, int $limit = 40): array { $query = trim(strtolower($query)); if ($query === '') { return []; } $terms = preg_split('/\s+/', $query) ?: [$query]; $hits = []; foreach (acp_search_index() as $row) { $score = 0; foreach ($terms as $term) { if ($term === '' || strpos($row['haystack'], $term) === false) { $score = 0; break; } $score += 1; if (strpos(strtolower($row['title']), $term) !== false) { $score += 3; } if (preg_match('/\b' . preg_quote($term, '/') . '\b/', $row['haystack'])) { $score += 2; } } if ($score > 0) { $row['score'] = $score + ($row['kind'] === 'page' ? 1 : 0); $hits[] = $row; } } usort($hits, static function (array $a, array $b): int { return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); }); return array_slice($hits, 0, $limit); } function acp_badge(string $key): ?int { $fn = 'acp_badge_' . $key; if (!function_exists($fn)) { return null; } $count = (int)$fn(); return $count > 0 ? $count : null; } function acp_count(string $sql, array $params = []): int { $row = db()->fetchOne($sql, $params); return is_array($row) && $row ? (int)reset($row) : 0; } function acp_badge_reports(): int { return acp_count("SELECT COUNT(*) AS `c` FROM `znote_player_reports` WHERE `status` IN (0, 1, 2);"); } function acp_badge_helpdesk(): int { return acp_count("SELECT COUNT(*) AS `c` FROM `znote_tickets` WHERE `status` <> 'CLOSED';"); } function acp_badge_gallery(): int { return acp_count("SELECT COUNT(*) AS `c` FROM `znote_images` WHERE `status` = 1;"); } function acp_badge_feedback(): int { $cache = new Cache('engine/cache/asideFeedbackCount'); if (!$cache->hasExpired()) { return (int)$cache->load(); } $new = 0; $threads = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_threads` WHERE `forum_id` = 4 AND `closed` = 0;"); if (is_array($threads)) { $staffIds = []; $staffs = db()->fetchAll("SELECT `id` FROM `players` WHERE `group_id` > 1;"); if (is_array($staffs)) { foreach ($staffs as $staff) { $staffIds[(int)$staff['id']] = true; } } foreach ($threads as $thread) { $answered = false; $posts = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_posts` WHERE `thread_id` = ?;", [(int)$thread['id']]); if (is_array($posts)) { foreach ($posts as $post) { if (isset($staffIds[(int)$post['player_id']])) { $answered = true; break; } } } if (!$answered) { $new++; } } } $cache->setContent($new); $cache->save(); return $new; } function acp_badge_shop_orders(): int { return acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;"); } function acp_flash(string $type, string $message): void { $_SESSION['acp_flash'][] = ['type' => $type, 'message' => $message]; } function acp_flash_success(string $m): void { acp_flash('success', $m); } function acp_flash_error(string $m): void { acp_flash('error', $m); } function acp_flash_info(string $m): void { acp_flash('info', $m); } function acp_take_flashes(): array { $flashes = $_SESSION['acp_flash'] ?? []; unset($_SESSION['acp_flash']); return is_array($flashes) ? $flashes : []; } function acp_csrf(): string { if (empty($_SESSION['acp_csrf'])) { $_SESSION['acp_csrf'] = bin2hex(random_bytes(16)); } return $_SESSION['acp_csrf']; } function acp_csrf_field(): string { return ''; } function acp_verify_csrf(): bool { return !empty($_POST['csrf_token']) && is_string($_POST['csrf_token']) && hash_equals(acp_csrf(), $_POST['csrf_token']); } /** * Dynamic add/remove-row key-value table fields, shared by any module that * needs an editable list of rows (originally built for settings.php's * 'table' schema field type, also used by the serverdata single-record * editors). Lives here rather than in one module file since only the active * module's own file is included per request (see admin/index.php). */ /** Convert a decoded JSON/array value into editable table rows. */ function acp_table_rows_from_json(array $field, array $decoded): array { $shape = $field['json_shape'] ?? 'map'; if ($shape === 'list') { $rows = array(); foreach ($decoded as $value) { if (!is_array($value)) continue; $row = array(); foreach ($field['columns'] as $colKey => $colDef) { $v = $value[$colKey] ?? null; $row[$colKey] = acp_table_cell_to_string($colDef, $v); } $rows[] = $row; } return $rows; } $rowKey = $field['row_key'] ?? 'id'; $valueColumn = $field['value_column'] ?? null; $rows = array(); foreach ($decoded as $key => $value) { $row = array($rowKey => (string)$key); if ($valueColumn !== null) { $row[$valueColumn] = is_scalar($value) ? (string)$value : ''; } else { foreach ($field['columns'] as $colKey => $colDef) { if ($colKey === $rowKey) continue; $v = is_array($value) ? ($value[$colKey] ?? null) : null; $row[$colKey] = acp_table_cell_to_string($colDef, $v); } } $rows[] = $row; } return $rows; } /** Convert one stored cell value into the string used to populate a form input. */ function acp_table_cell_to_string(array $colDef, $v): string { switch ($colDef['type'] ?? 'text') { case 'nullable_int': return ($v === false || $v === null) ? '' : (string)(int)$v; case 'bool': return !empty($v) ? '1' : ''; default: return ($v === null) ? '' : (string)$v; } } /** Render the form control for one table cell (name attribute pre-built by the caller). */ function acp_table_cell_input(array $colDef, string $name, string $value): string { $type = $colDef['type'] ?? 'text'; if ($type === 'bool') { return ''; } if ($type === 'select') { $html = ''; return $html; } if ($type === 'textarea') { return ''; } $inputType = in_array($type, array('int', 'nullable_int'), true) ? 'number' : 'text'; return ''; } /** Convert one posted cell value back into its typed PHP value for storage. */ function acp_table_cell_from_post(array $colDef, $v) { switch ($colDef['type'] ?? 'text') { case 'nullable_int': $v = trim((string)$v); return ($v === '') ? false : (int)$v; case 'int': return (int)trim((string)$v); case 'bool': return !empty($v); default: return trim((string)$v); } } /** * acp_table_json_from_rows() (the reverse conversion) is NOT here - the * map-shape branch's key validation differs per caller (settings.php requires * numeric row keys for its vocation/town id fields; the serverdata editors * need arbitrary text keys for item attributes), so each defines its own. */ function acp_card_open(string $title = '', string $subtitle = '', string $extraClass = ''): void { echo '
'; if ($title !== '') { echo '

' . h($title) . '

'; if ($subtitle !== '') { echo '

' . h($subtitle) . '

'; } echo '
'; } echo '
'; } function acp_card_close(): void { echo '
'; } function acp_empty(string $message, string $icon = 'fa-inbox'): void { echo '

' . h($message) . '

'; } function acp_stat(string $label, $value, string $icon = 'fa-circle-o', ?string $href = null, string $tone = 'blue'): void { $display = is_int($value) ? number_format($value) : (string)$value; $inner = '' . '' . '' . h($label) . '' . '' . h($display) . '' . ''; $class = 'acp-stat acp-stat--' . h($tone); echo $href !== null ? '' . $inner . '' : '
' . $inner . '
'; }