$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/';
?>
= h($note) ?>
>
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 '