| @@ -0,0 +1,196 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Creatures | |
| 4 | + | * Icon: fa-paw | |
| 5 | + | * Group: Server Info | |
| 6 | + | * Order: 13 | |
| 7 | + | * Description: Add or edit a single monster without re-uploading the whole monster set. | |
| 8 | + | * Hidden: true | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | ||
| 12 | + | if (!defined('ACP_ROOT')) { | |
| 13 | + | http_response_code(403); | |
| 14 | + | die('Direct access denied.'); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | $creatures = serverdata_load('creatures'); | |
| 18 | + | $creatures = is_array($creatures) ? $creatures : array(); | |
| 19 | + | ||
| 20 | + | if (!$creatures && !serverdata_override_table_exists()) { | |
| 21 | + | acp_flash_error(t_default('acp.crted.no_data', 'Upload your monster data on Server Info first - there is nothing published to edit yet.')); | |
| 22 | + | acp_redirect('serverinfo'); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 26 | + | $adminName = (string)($GLOBALS['user_data']['name'] ?? ''); | |
| 27 | + | $action = (string)($_POST['crted_action'] ?? ''); | |
| 28 | + | ||
| 29 | + | if ($action === 'save') { | |
| 30 | + | $originalName = trim((string)($_POST['original_name'] ?? '')); | |
| 31 | + | $name = trim((string)($_POST['name'] ?? '')); | |
| 32 | + | ||
| 33 | + | if ($name === '') { | |
| 34 | + | acp_flash_error(t_default('acp.crted.missing_fields', 'A name is required.')); | |
| 35 | + | acp_redirect('creatures_editor'); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | $record = array( | |
| 39 | + | 'health' => max(0, intv($_POST['health'] ?? 0)), | |
| 40 | + | 'experience' => max(0, intv($_POST['experience'] ?? 0)), | |
| 41 | + | 'speed' => max(0, intv($_POST['speed'] ?? 0)), | |
| 42 | + | 'race' => trim((string)($_POST['race'] ?? '')), | |
| 43 | + | 'looktype' => max(0, intv($_POST['looktype'] ?? 0)), | |
| 44 | + | ); | |
| 45 | + | ||
| 46 | + | $ok = serverdata_override_set('creatures', $name, $record, $adminName); | |
| 47 | + | ||
| 48 | + | // Renaming: the old key must stop winning over the base cache too. | |
| 49 | + | if ($ok && $originalName !== '' && $originalName !== $name) { | |
| 50 | + | serverdata_override_delete('creatures', $originalName, $adminName); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if ($ok) { | |
| 54 | + | acp_log('serverdata.creature_save', $name); | |
| 55 | + | acp_flash_success(t_default('acp.crted.saved', '{name} saved.', ['name' => $name])); | |
| 56 | + | } else { | |
| 57 | + | acp_flash_error(t_default('acp.crted.save_failed', 'Could not save - run the pending database migration first (Admin Panel > Migrations).')); | |
| 58 | + | } | |
| 59 | + | acp_redirect('creatures_editor'); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if ($action === 'delete') { | |
| 63 | + | $name = trim((string)($_POST['name'] ?? '')); | |
| 64 | + | if (serverdata_override_delete('creatures', $name, $adminName)) { | |
| 65 | + | acp_log('serverdata.creature_delete', $name); | |
| 66 | + | acp_flash_success(t_default('acp.crted.removed', '{name} removed.', ['name' => $name])); | |
| 67 | + | } else { | |
| 68 | + | acp_flash_error(t_default('acp.crted.remove_failed', 'Could not remove that creature.')); | |
| 69 | + | } | |
| 70 | + | acp_redirect('creatures_editor'); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | $editName = trim((string)($_GET['edit'] ?? '')); | |
| 75 | + | $editing = null; | |
| 76 | + | if ($editName !== '') { | |
| 77 | + | foreach ($creatures as $c) { | |
| 78 | + | if (($c['name'] ?? '') === $editName) { | |
| 79 | + | $editing = $c; | |
| 80 | + | break; | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | $list = $creatures; | |
| 86 | + | usort($list, static function (array $a, array $b): int { | |
| 87 | + | return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); | |
| 88 | + | }); | |
| 89 | + | ?> | |
| 90 | + | ||
| 91 | + | <section class="acp-card"> | |
| 92 | + | <header class="acp-card-head"> | |
| 93 | + | <h2><?= $editing ? h(t_default('acp.crted.edit_title', 'Edit {name}', ['name' => $editName])) : h(t_default('acp.crted.add_title', 'Add a creature')) ?></h2> | |
| 94 | + | <p><?= h(t_default('acp.crted.sub', 'Saved separately from the uploaded monster files - a later re-upload will not overwrite this.')) ?></p> | |
| 95 | + | </header> | |
| 96 | + | <div class="acp-card-body"> | |
| 97 | + | <form method="post"> | |
| 98 | + | <?= acp_csrf_field() ?> | |
| 99 | + | <input type="hidden" name="crted_action" value="save"> | |
| 100 | + | <input type="hidden" name="original_name" value="<?= h($editName) ?>"> | |
| 101 | + | ||
| 102 | + | <div class="acp-row"> | |
| 103 | + | <div class="acp-field"> | |
| 104 | + | <label class="acp-label" for="crted_name"><?= t_default('acp.crted.field_name', 'Name') ?></label> | |
| 105 | + | <input class="acp-input" type="text" id="crted_name" name="name" value="<?= h((string)($editing['name'] ?? '')) ?>" required> | |
| 106 | + | </div> | |
| 107 | + | <div class="acp-field"> | |
| 108 | + | <label class="acp-label" for="crted_race"><?= t_default('acp.crted.field_race', 'Race') ?></label> | |
| 109 | + | <input class="acp-input" type="text" id="crted_race" name="race" value="<?= h((string)($editing['race'] ?? '')) ?>" placeholder="blood, venom, undead..."> | |
| 110 | + | </div> | |
| 111 | + | </div> | |
| 112 | + | ||
| 113 | + | <div class="acp-row"> | |
| 114 | + | <div class="acp-field"> | |
| 115 | + | <label class="acp-label" for="crted_health"><?= t_default('acp.crted.field_health', 'Health') ?></label> | |
| 116 | + | <input class="acp-input" type="number" min="0" id="crted_health" name="health" value="<?= (int)($editing['health'] ?? 0) ?>"> | |
| 117 | + | </div> | |
| 118 | + | <div class="acp-field"> | |
| 119 | + | <label class="acp-label" for="crted_experience"><?= t_default('acp.crted.field_experience', 'Experience') ?></label> | |
| 120 | + | <input class="acp-input" type="number" min="0" id="crted_experience" name="experience" value="<?= (int)($editing['experience'] ?? 0) ?>"> | |
| 121 | + | </div> | |
| 122 | + | <div class="acp-field"> | |
| 123 | + | <label class="acp-label" for="crted_speed"><?= t_default('acp.crted.field_speed', 'Speed') ?></label> | |
| 124 | + | <input class="acp-input" type="number" min="0" id="crted_speed" name="speed" value="<?= (int)($editing['speed'] ?? 0) ?>"> | |
| 125 | + | </div> | |
| 126 | + | <div class="acp-field"> | |
| 127 | + | <label class="acp-label" for="crted_looktype"><?= t_default('acp.crted.field_looktype', 'Looktype') ?></label> | |
| 128 | + | <input class="acp-input" type="number" min="0" id="crted_looktype" name="looktype" value="<?= (int)($editing['looktype'] ?? 0) ?>"> | |
| 129 | + | </div> | |
| 130 | + | </div> | |
| 131 | + | ||
| 132 | + | <div class="acp-actions"> | |
| 133 | + | <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= $editing ? t_default('acp.crted.save_btn', 'Save') : t_default('acp.crted.add_btn', 'Add creature') ?></button> | |
| 134 | + | <?php if ($editing): ?> | |
| 135 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('creatures_editor')) ?>"><?= t_default('acp.crted.cancel', 'Cancel edit') ?></a> | |
| 136 | + | <?php endif; ?> | |
| 137 | + | </div> | |
| 138 | + | </form> | |
| 139 | + | </div> | |
| 140 | + | </section> | |
| 141 | + | ||
| 142 | + | <section class="acp-card"> | |
| 143 | + | <header class="acp-card-head"> | |
| 144 | + | <h2><?= t_default('acp.crted.list_title', 'Published creatures') ?></h2> | |
| 145 | + | </header> | |
| 146 | + | <div class="acp-card-body is-flush"> | |
| 147 | + | <?php if ($list): ?> | |
| 148 | + | <?php if (count($list) > 8): ?> | |
| 149 | + | <div class="acp-toolbar"> | |
| 150 | + | <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;"> | |
| 151 | + | <input class="acp-input" type="search" data-acp-search-input="crtedTable" | |
| 152 | + | placeholder="<?= h(t_default('acp.crted.search_placeholder', 'Search creatures...')) ?>"> | |
| 153 | + | </div> | |
| 154 | + | <span class="is-muted" data-acp-search-count="crtedTable"></span> | |
| 155 | + | </div> | |
| 156 | + | <?php endif; ?> | |
| 157 | + | <div class="acp-table-wrap"> | |
| 158 | + | <table class="acp-table" data-sortable id="crtedTable"> | |
| 159 | + | <thead> | |
| 160 | + | <tr> | |
| 161 | + | <th><?= t_default('acp.crted.col_name', 'Name') ?></th> | |
| 162 | + | <th class="is-num"><?= t_default('acp.crted.col_health', 'Health') ?></th> | |
| 163 | + | <th class="is-num"><?= t_default('acp.crted.col_experience', 'Experience') ?></th> | |
| 164 | + | <th><?= t_default('acp.crted.col_race', 'Race') ?></th> | |
| 165 | + | <th class="is-num"><?= t_default('acp.crted.col_actions', 'Actions') ?></th> | |
| 166 | + | </tr> | |
| 167 | + | </thead> | |
| 168 | + | <tbody> | |
| 169 | + | <?php foreach ($list as $c): ?> | |
| 170 | + | <?php $cName = (string)($c['name'] ?? ''); ?> | |
| 171 | + | <tr data-acp-search="<?= h(strtolower($cName)) ?>"> | |
| 172 | + | <td><?= h($cName) ?></td> | |
| 173 | + | <td class="is-num"><?= (int)($c['health'] ?? 0) ?></td> | |
| 174 | + | <td class="is-num"><?= (int)($c['experience'] ?? 0) ?></td> | |
| 175 | + | <td class="is-muted"><?= h((string)($c['race'] ?? '')) ?></td> | |
| 176 | + | <td class="is-nowrap is-num"> | |
| 177 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('creatures_editor', array('edit' => $cName))) ?>"><i class="fa fa-pencil"></i></a> | |
| 178 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.crted.confirm_remove', 'Remove this creature?')) ?>"> | |
| 179 | + | <?= acp_csrf_field() ?> | |
| 180 | + | <input type="hidden" name="crted_action" value="delete"> | |
| 181 | + | <input type="hidden" name="name" value="<?= h($cName) ?>"> | |
| 182 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-times"></i></button> | |
| 183 | + | </form> | |
| 184 | + | </td> | |
| 185 | + | </tr> | |
| 186 | + | <?php endforeach; ?> | |
| 187 | + | </tbody> | |
| 188 | + | </table> | |
| 189 | + | </div> | |
| 190 | + | <?php else: ?> | |
| 191 | + | <?php acp_empty(t_default('acp.crted.empty', 'No creatures published yet.'), 'fa-paw'); ?> | |
| 192 | + | <?php endif; ?> | |
| 193 | + | </div> | |
| 194 | + | </section> | |
| 195 | + | ||
| 196 | + | <p class="acp-hint"><a href="<?= h(acp_url('serverinfo')) ?>"><i class="fa fa-arrow-left"></i> <?= t_default('acp.crted.back', 'Back to Server Info') ?></a></p> |
| @@ -0,0 +1,440 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Dashboard | |
| 4 | + | * Icon: fa-tachometer | |
| 5 | + | * Group: Overview | |
| 6 | + | * Order: 10 | |
| 7 | + | * Description: Server and community at a glance. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // OTHIRE has no accounts.name column - it identifies accounts by number. | |
| 16 | + | $accNameCol = znote_server_adapter()->accountDisplayColumn(); | |
| 17 | + | ||
| 18 | + | // --------------------------------------------------------------------------- | |
| 19 | + | // Counters. acp_count() returns 0 for a table this engine does not have, | |
| 20 | + | // so an unusual schema degrades to a zero instead of a fatal error. | |
| 21 | + | // --------------------------------------------------------------------------- | |
| 22 | + | $statAccounts = acp_count("SELECT COUNT(*) AS `c` FROM `accounts`;"); | |
| 23 | + | $statPlayers = acp_count("SELECT COUNT(*) AS `c` FROM `players`;"); | |
| 24 | + | $statGuilds = acp_count("SELECT COUNT(*) AS `c` FROM `guilds`;"); | |
| 25 | + | $statHouses = acp_count("SELECT COUNT(*) AS `c` FROM `houses`;"); | |
| 26 | + | ||
| 27 | + | $statOnline = znote_server_adapter()->onlineCount(); | |
| 28 | + | ||
| 29 | + | $statPoints = acp_count("SELECT COALESCE(SUM(`points`), 0) AS `c` FROM `znote_accounts`;"); | |
| 30 | + | $statOrders = acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;"); | |
| 31 | + | ||
| 32 | + | // Moderation queues - reuse the same counters the sidebar badges use. | |
| 33 | + | $queueReports = acp_badge_reports(); | |
| 34 | + | $queueTickets = acp_badge_helpdesk(); | |
| 35 | + | $queueImages = acp_badge_gallery(); | |
| 36 | + | $queueFeedback = acp_badge_feedback(); | |
| 37 | + | ||
| 38 | + | // --------------------------------------------------------------------------- | |
| 39 | + | // Recent activity | |
| 40 | + | // --------------------------------------------------------------------------- | |
| 41 | + | $latestAccounts = db()->fetchAll(" | |
| 42 | + | SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`created`, `za`.`points` | |
| 43 | + | FROM `znote_accounts` `za` | |
| 44 | + | INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id` | |
| 45 | + | ORDER BY `za`.`created` DESC | |
| 46 | + | LIMIT 10; | |
| 47 | + | "); | |
| 48 | + | ||
| 49 | + | $latestPlayers = db()->fetchAll(" | |
| 50 | + | SELECT `name`, `level`, `vocation` | |
| 51 | + | FROM `players` | |
| 52 | + | ORDER BY `id` DESC | |
| 53 | + | LIMIT 10; | |
| 54 | + | "); | |
| 55 | + | ||
| 56 | + | $topPoints = db()->fetchAll(" | |
| 57 | + | SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`points` | |
| 58 | + | FROM `znote_accounts` `za` | |
| 59 | + | INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id` | |
| 60 | + | WHERE `za`.`points` > 0 | |
| 61 | + | ORDER BY `za`.`points` DESC | |
| 62 | + | LIMIT 10; | |
| 63 | + | "); | |
| 64 | + | ||
| 65 | + | $recentShopPurchases = znote_table_exists('znote_shop_logs') | |
| 66 | + | ? db()->fetchAll(" | |
| 67 | + | SELECT `l`.`account_id`, {$accNameCol} AS `account_name`, `l`.`itemid`, `l`.`type`, `l`.`count`, `l`.`points`, `l`.`time` | |
| 68 | + | FROM `znote_shop_logs` `l` | |
| 69 | + | LEFT JOIN `accounts` `a` ON `a`.`id` = `l`.`account_id` | |
| 70 | + | ORDER BY `l`.`id` DESC | |
| 71 | + | LIMIT 8; | |
| 72 | + | ") | |
| 73 | + | : false; | |
| 74 | + | $recentShopPurchases = is_array($recentShopPurchases) ? $recentShopPurchases : array(); | |
| 75 | + | ||
| 76 | + | $shopItemNames = function_exists('getItemList') ? getItemList() : array(); | |
| 77 | + | $shopOrderTypes = array( | |
| 78 | + | 1 => t('acp.sord.type_item'), | |
| 79 | + | 2 => t('acp.sord.type_premium'), | |
| 80 | + | 3 => t('acp.sord.type_gender'), | |
| 81 | + | 4 => t('acp.sord.type_name'), | |
| 82 | + | 5 => t('acp.sord.type_outfit'), | |
| 83 | + | 6 => t('acp.sord.type_mount'), | |
| 84 | + | 7 => t('acp.sord.type_custom'), | |
| 85 | + | 8 => t('acp.sord.type_custom'), | |
| 86 | + | ); | |
| 87 | + | ||
| 88 | + | $recentPointsPurchases = znote_table_exists('znote_payment_transactions') | |
| 89 | + | ? db()->fetchAll(" | |
| 90 | + | SELECT `t`.`account_id`, {$accNameCol} AS `account_name`, `t`.`provider`, `t`.`price`, `t`.`currency`, `t`.`points`, `t`.`credited`, `t`.`created_at` | |
| 91 | + | FROM `znote_payment_transactions` `t` | |
| 92 | + | LEFT JOIN `accounts` `a` ON `a`.`id` = `t`.`account_id` | |
| 93 | + | ORDER BY `t`.`id` DESC | |
| 94 | + | LIMIT 8; | |
| 95 | + | ") | |
| 96 | + | : false; | |
| 97 | + | $recentPointsPurchases = is_array($recentPointsPurchases) ? $recentPointsPurchases : array(); | |
| 98 | + | ||
| 99 | + | // znote row: keep the stored version in step with the running one, the way | |
| 100 | + | // the old admin.php did on every visit. | |
| 101 | + | $znote = user_znote_data('version', 'installed', 'cached'); | |
| 102 | + | if (is_array($znote) && ($znote['version'] ?? null) !== $version) { | |
| 103 | + | $oldVersion = (string)($znote['version'] ?? ''); | |
| 104 | + | db()->execute("UPDATE `znote` SET `version` = ?;", [$version]); | |
| 105 | + | acp_log('system.version_sync', $version, ['from' => $oldVersion]); | |
| 106 | + | $znote['version'] = $version; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | $obSteps = array( | |
| 110 | + | array( | |
| 111 | + | 'done' => !empty($config['twoFactorAuthenticator']), | |
| 112 | + | 'label' => t_default('acp.dash.ob_2fa', 'Enable two-factor authentication'), | |
| 113 | + | 'url' => acp_url('settings') . '#set_twoFactorAuthenticator', | |
| 114 | + | ), | |
| 115 | + | array( | |
| 116 | + | 'done' => function_exists('znote_backups_list') && count(znote_backups_list()) > 0, | |
| 117 | + | 'label' => t_default('acp.dash.ob_backup', 'Create your first backup'), | |
| 118 | + | 'url' => acp_url('backups'), | |
| 119 | + | ), | |
| 120 | + | array( | |
| 121 | + | 'done' => function_exists('scheduler_enabled') && scheduler_enabled('backups'), | |
| 122 | + | 'label' => t_default('acp.dash.ob_scheduler', 'Turn on automatic backups'), | |
| 123 | + | 'url' => acp_url('scheduler'), | |
| 124 | + | ), | |
| 125 | + | array( | |
| 126 | + | 'done' => function_exists('znote_migrations_pending') && !znote_migrations_pending(), | |
| 127 | + | 'label' => t_default('acp.dash.ob_migrations', 'Apply pending database migrations'), | |
| 128 | + | 'url' => acp_url('migrations'), | |
| 129 | + | ), | |
| 130 | + | ); | |
| 131 | + | $obRemaining = count(array_filter($obSteps, static fn(array $s): bool => !$s['done'])); | |
| 132 | + | ?> | |
| 133 | + | ||
| 134 | + | <div class="acp-stats"> | |
| 135 | + | <?php | |
| 136 | + | acp_stat(t('acp.dash.stat_accounts'), $statAccounts, 'fa-user-plus', null, 'blue'); | |
| 137 | + | acp_stat(t('acp.dash.stat_characters'), $statPlayers, 'fa-users', null, 'red'); | |
| 138 | + | acp_stat(t('acp.dash.stat_online'), $statOnline, 'fa-signal', null, 'teal'); | |
| 139 | + | acp_stat(t('acp.dash.stat_guilds'), $statGuilds, 'fa-shield', null, 'green'); | |
| 140 | + | acp_stat(t('acp.dash.stat_houses'), $statHouses, 'fa-home', null, 'amber'); | |
| 141 | + | acp_stat(t('acp.dash.stat_points'), $statPoints, 'fa-diamond', acp_url('shop'), 'purple'); | |
| 142 | + | ?> | |
| 143 | + | </div> | |
| 144 | + | ||
| 145 | + | <?php if ($obRemaining > 0): ?> | |
| 146 | + | <section class="acp-card"> | |
| 147 | + | <header class="acp-card-head"> | |
| 148 | + | <h2><?= t_default('acp.dash.ob_title', 'Getting started') ?></h2> | |
| 149 | + | <p><?= h(t_default('acp.dash.ob_sub', '{n} step(s) left - this card goes away once they are all done.', ['n' => $obRemaining])) ?></p> | |
| 150 | + | </header> | |
| 151 | + | <div class="acp-card-body is-flush"> | |
| 152 | + | <div class="acp-table-wrap"> | |
| 153 | + | <table class="acp-table"> | |
| 154 | + | <tbody> | |
| 155 | + | <?php foreach ($obSteps as $step): ?> | |
| 156 | + | <tr> | |
| 157 | + | <td> | |
| 158 | + | <i class="fa <?= $step['done'] ? 'fa-check-circle' : 'fa-circle-o' ?> is-muted"></i> | |
| 159 | + | <?= h($step['label']) ?> | |
| 160 | + | </td> | |
| 161 | + | <td class="is-num"> | |
| 162 | + | <span class="acp-pill <?= $step['done'] ? 'acp-pill--green' : 'acp-pill--amber' ?>"> | |
| 163 | + | <?= $step['done'] ? t('acp.dash.enabled') : t_default('acp.dash.ob_todo', 'To do') ?> | |
| 164 | + | </span> | |
| 165 | + | </td> | |
| 166 | + | <td class="is-nowrap is-num"> | |
| 167 | + | <?php if (!$step['done']): ?> | |
| 168 | + | <a href="<?= h($step['url']) ?>"><?= t('acp.dash.review') ?></a> | |
| 169 | + | <?php endif; ?> | |
| 170 | + | </td> | |
| 171 | + | </tr> | |
| 172 | + | <?php endforeach; ?> | |
| 173 | + | </tbody> | |
| 174 | + | </table> | |
| 175 | + | </div> | |
| 176 | + | </div> | |
| 177 | + | </section> | |
| 178 | + | <?php endif; ?> | |
| 179 | + | ||
| 180 | + | <div class="acp-grid acp-grid--2"> | |
| 181 | + | ||
| 182 | + | <!-- ------------------------------------------------ Moderation queue --> | |
| 183 | + | <section class="acp-card"> | |
| 184 | + | <header class="acp-card-head"> | |
| 185 | + | <h2><?= t('acp.dash.attention_title') ?></h2> | |
| 186 | + | <p><?= t('acp.dash.attention_sub') ?></p> | |
| 187 | + | </header> | |
| 188 | + | <div class="acp-card-body is-flush"> | |
| 189 | + | <div class="acp-table-wrap"> | |
| 190 | + | <table class="acp-table"> | |
| 191 | + | <tbody> | |
| 192 | + | <tr> | |
| 193 | + | <td><i class="fa fa-bug is-muted"></i> <?= t('acp.dash.bug_reports') ?></td> | |
| 194 | + | <td class="is-num"> | |
| 195 | + | <span class="acp-pill <?= $queueReports > 0 ? 'acp-pill--red' : 'acp-pill--green' ?>"><?= (int)$queueReports ?></span> | |
| 196 | + | </td> | |
| 197 | + | <td class="is-nowrap is-num"><a href="<?= h(acp_url('reports')) ?>"><?= t('acp.dash.review') ?></a></td> | |
| 198 | + | </tr> | |
| 199 | + | <tr> | |
| 200 | + | <td><i class="fa fa-life-ring is-muted"></i> <?= t('acp.dash.helpdesk_open') ?></td> | |
| 201 | + | <td class="is-num"> | |
| 202 | + | <span class="acp-pill <?= $queueTickets > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueTickets ?></span> | |
| 203 | + | </td> | |
| 204 | + | <td class="is-nowrap is-num"><a href="<?= h(acp_url('helpdesk')) ?>"><?= t('acp.dash.review') ?></a></td> | |
| 205 | + | </tr> | |
| 206 | + | <tr> | |
| 207 | + | <td><i class="fa fa-picture-o is-muted"></i> <?= t('acp.dash.images_pending') ?></td> | |
| 208 | + | <td class="is-num"> | |
| 209 | + | <span class="acp-pill <?= $queueImages > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueImages ?></span> | |
| 210 | + | </td> | |
| 211 | + | <td class="is-nowrap is-num"><a href="<?= h(acp_url('gallery')) ?>"><?= t('acp.dash.review') ?></a></td> | |
| 212 | + | </tr> | |
| 213 | + | <tr> | |
| 214 | + | <td><i class="fa fa-comments-o is-muted"></i> <?= t('acp.dash.feedback_open') ?></td> | |
| 215 | + | <td class="is-num"> | |
| 216 | + | <span class="acp-pill <?= $queueFeedback > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueFeedback ?></span> | |
| 217 | + | </td> | |
| 218 | + | <td class="is-nowrap is-num"><a href="<?= h(acp_site('forum.php?cat=4')) ?>"><?= t('acp.dash.open') ?></a></td> | |
| 219 | + | </tr> | |
| 220 | + | <tr> | |
| 221 | + | <td><i class="fa fa-shopping-cart is-muted"></i> <?= t('acp.dash.orders_pending') ?></td> | |
| 222 | + | <td class="is-num"> | |
| 223 | + | <span class="acp-pill <?= $statOrders > 0 ? 'acp-pill--blue' : 'acp-pill--green' ?>"><?= (int)$statOrders ?></span> | |
| 224 | + | </td> | |
| 225 | + | <td class="is-nowrap is-num"><a href="<?= h(acp_url('shop_orders')) ?>"><?= t('acp.dash.open') ?></a></td> | |
| 226 | + | </tr> | |
| 227 | + | </tbody> | |
| 228 | + | </table> | |
| 229 | + | </div> | |
| 230 | + | </div> | |
| 231 | + | </section> | |
| 232 | + | ||
| 233 | + | <!-- ------------------------------------------------------ Environment --> | |
| 234 | + | <section class="acp-card"> | |
| 235 | + | <header class="acp-card-head"> | |
| 236 | + | <h2><?= t('acp.dash.env_title') ?></h2> | |
| 237 | + | <p><?= t('acp.dash.env_sub') ?></p> | |
| 238 | + | </header> | |
| 239 | + | <div class="acp-card-body"> | |
| 240 | + | <dl class="acp-dl"> | |
| 241 | + | <dt>ZnoteX</dt> | |
| 242 | + | <dd><?= h($version) ?><?= is_array($znote) && isset($znote['version']) ? '' : ' <span class="acp-pill acp-pill--red">'. t('acp.dash.znote_table_missing') .'</span>' ?></dd> | |
| 243 | + | ||
| 244 | + | <dt>PHP</dt> | |
| 245 | + | <dd><?= h(PHP_VERSION) ?></dd> | |
| 246 | + | ||
| 247 | + | <dt><?= t('acp.dash.server_engine') ?></dt> | |
| 248 | + | <dd><span class="acp-pill acp-pill--blue"><?= h(serverEngineReal()) ?></span></dd> | |
| 249 | + | ||
| 250 | + | <dt><?= t('acp.dash.database') ?></dt> | |
| 251 | + | <dd><?= h($config['sqlDatabase'] ?? '') ?> @ <?= h($config['sqlHost'] ?? '') ?></dd> | |
| 252 | + | ||
| 253 | + | <dt><?= t('acp.dash.site_url') ?></dt> | |
| 254 | + | <dd><a href="<?= h($config['site_url'] ?? '#') ?>" target="_blank" rel="noopener"><?= h($config['site_url'] ?? '') ?></a></dd> | |
| 255 | + | ||
| 256 | + | <dt><?= t('acp.dash.installed') ?></dt> | |
| 257 | + | <dd><?= is_array($znote) && !empty($znote['installed']) ? h(getClock((int)$znote['installed'], true)) : '—' ?></dd> | |
| 258 | + | ||
| 259 | + | <dt><?= t('acp.dash.last_cache') ?></dt> | |
| 260 | + | <dd><?= is_array($znote) && !empty($znote['cached']) ? h(getClock((int)$znote['cached'], true)) : '—' ?></dd> | |
| 261 | + | ||
| 262 | + | <dt><?= t('acp.dash.two_factor') ?></dt> | |
| 263 | + | <dd><?= !empty($config['twoFactorAuthenticator']) | |
| 264 | + | ? '<span class="acp-pill acp-pill--green">'. t('acp.dash.enabled') .'</span>' | |
| 265 | + | : '<span class="acp-pill acp-pill--grey">'. t('acp.dash.disabled') .'</span>' ?></dd> | |
| 266 | + | </dl> | |
| 267 | + | </div> | |
| 268 | + | </section> | |
| 269 | + | </div> | |
| 270 | + | ||
| 271 | + | <div class="acp-grid acp-grid--3"> | |
| 272 | + | ||
| 273 | + | <!-- ------------------------------------------------- Latest accounts --> | |
| 274 | + | <section class="acp-card"> | |
| 275 | + | <header class="acp-card-head"><h2><?= t('acp.dash.newest_accounts') ?></h2></header> | |
| 276 | + | <div class="acp-card-body is-flush"> | |
| 277 | + | <?php if (is_array($latestAccounts) && $latestAccounts): ?> | |
| 278 | + | <div class="acp-table-wrap"> | |
| 279 | + | <table class="acp-table"> | |
| 280 | + | <thead> | |
| 281 | + | <tr><th><?= t('acp.dash.col_account') ?></th><th><?= t('acp.dash.col_created') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr> | |
| 282 | + | </thead> | |
| 283 | + | <tbody> | |
| 284 | + | <?php foreach ($latestAccounts as $row): ?> | |
| 285 | + | <tr> | |
| 286 | + | <td><?= h($row['account_name']) ?></td> | |
| 287 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created'], true)) ?></td> | |
| 288 | + | <td class="is-num"><?= (int)$row['points'] ?></td> | |
| 289 | + | </tr> | |
| 290 | + | <?php endforeach; ?> | |
| 291 | + | </tbody> | |
| 292 | + | </table> | |
| 293 | + | </div> | |
| 294 | + | <?php else: ?> | |
| 295 | + | <?php acp_empty(t('acp.dash.no_accounts'), 'fa-user-o'); ?> | |
| 296 | + | <?php endif; ?> | |
| 297 | + | </div> | |
| 298 | + | </section> | |
| 299 | + | ||
| 300 | + | <!-- -------------------------------------------------- Latest players --> | |
| 301 | + | <section class="acp-card"> | |
| 302 | + | <header class="acp-card-head"><h2><?= t('acp.dash.newest_characters') ?></h2></header> | |
| 303 | + | <div class="acp-card-body is-flush"> | |
| 304 | + | <?php if (is_array($latestPlayers) && $latestPlayers): ?> | |
| 305 | + | <div class="acp-table-wrap"> | |
| 306 | + | <table class="acp-table"> | |
| 307 | + | <thead> | |
| 308 | + | <tr><th><?= t('acp.dash.col_name') ?></th><th><?= t('acp.dash.col_vocation') ?></th><th class="is-num"><?= t('acp.dash.col_level') ?></th></tr> | |
| 309 | + | </thead> | |
| 310 | + | <tbody> | |
| 311 | + | <?php foreach ($latestPlayers as $row): ?> | |
| 312 | + | <tr> | |
| 313 | + | <td> | |
| 314 | + | <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$row['name']))) ?>" target="_blank" rel="noopener"> | |
| 315 | + | <?= h($row['name']) ?> | |
| 316 | + | </a> | |
| 317 | + | </td> | |
| 318 | + | <td class="is-muted"><?= h(vocation_id_to_name((int)$row['vocation'])) ?></td> | |
| 319 | + | <td class="is-num"><?= (int)$row['level'] ?></td> | |
| 320 | + | </tr> | |
| 321 | + | <?php endforeach; ?> | |
| 322 | + | </tbody> | |
| 323 | + | </table> | |
| 324 | + | </div> | |
| 325 | + | <?php else: ?> | |
| 326 | + | <?php acp_empty(t('acp.dash.no_characters'), 'fa-user-o'); ?> | |
| 327 | + | <?php endif; ?> | |
| 328 | + | </div> | |
| 329 | + | </section> | |
| 330 | + | ||
| 331 | + | <!-- ---------------------------------------------------- Top balances --> | |
| 332 | + | <section class="acp-card"> | |
| 333 | + | <header class="acp-card-head"><h2><?= t('acp.dash.top_balances') ?></h2></header> | |
| 334 | + | <div class="acp-card-body is-flush"> | |
| 335 | + | <?php if (is_array($topPoints) && $topPoints): ?> | |
| 336 | + | <div class="acp-table-wrap"> | |
| 337 | + | <table class="acp-table"> | |
| 338 | + | <thead> | |
| 339 | + | <tr><th>#</th><th><?= t('acp.dash.col_account') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr> | |
| 340 | + | </thead> | |
| 341 | + | <tbody> | |
| 342 | + | <?php $rank = 0; foreach ($topPoints as $row): $rank++; ?> | |
| 343 | + | <tr> | |
| 344 | + | <td class="is-muted"><?= $rank ?></td> | |
| 345 | + | <td><?= h($row['account_name']) ?></td> | |
| 346 | + | <td class="is-num"><strong><?= number_format((int)$row['points']) ?></strong></td> | |
| 347 | + | </tr> | |
| 348 | + | <?php endforeach; ?> | |
| 349 | + | </tbody> | |
| 350 | + | </table> | |
| 351 | + | </div> | |
| 352 | + | <?php else: ?> | |
| 353 | + | <?php acp_empty(t('acp.dash.no_points'), 'fa-diamond'); ?> | |
| 354 | + | <?php endif; ?> | |
| 355 | + | </div> | |
| 356 | + | </section> | |
| 357 | + | </div> | |
| 358 | + | ||
| 359 | + | <div class="acp-grid acp-grid--2"> | |
| 360 | + | ||
| 361 | + | <!-- ------------------------------------------- Recent shop purchases --> | |
| 362 | + | <section class="acp-card"> | |
| 363 | + | <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_shop', 'Recent Shop Purchases') ?></h2></header> | |
| 364 | + | <div class="acp-card-body is-flush"> | |
| 365 | + | <?php if ($recentShopPurchases): ?> | |
| 366 | + | <div class="acp-table-wrap"> | |
| 367 | + | <table class="acp-table"> | |
| 368 | + | <thead> | |
| 369 | + | <tr> | |
| 370 | + | <th><?= t('acp.dash.col_account') ?></th> | |
| 371 | + | <th><?= t('acp.sord.col_item') ?></th> | |
| 372 | + | <th class="is-num"><?= t('acp.sord.col_points') ?></th> | |
| 373 | + | <th><?= t('acp.sord.col_date') ?></th> | |
| 374 | + | </tr> | |
| 375 | + | </thead> | |
| 376 | + | <tbody> | |
| 377 | + | <?php foreach ($recentShopPurchases as $row): | |
| 378 | + | $itemId = intv($row['itemid'] ?? 0); | |
| 379 | + | ?> | |
| 380 | + | <tr> | |
| 381 | + | <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td> | |
| 382 | + | <td> | |
| 383 | + | <?php if ($itemId > 0): ?> | |
| 384 | + | <?= h($shopItemNames[$itemId] ?? t('acp.sord.item_unknown')) ?> | |
| 385 | + | <?php else: ?> | |
| 386 | + | <span class="acp-pill acp-pill--grey"><?= h($shopOrderTypes[(int)($row['type'] ?? 0)] ?? t('acp.sord.type_unknown')) ?></span> | |
| 387 | + | <?php endif; ?> | |
| 388 | + | </td> | |
| 389 | + | <td class="is-num"><?= (int)($row['points'] ?? 0) ?></td> | |
| 390 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)($row['time'] ?? 0), true)) ?></td> | |
| 391 | + | </tr> | |
| 392 | + | <?php endforeach; ?> | |
| 393 | + | </tbody> | |
| 394 | + | </table> | |
| 395 | + | </div> | |
| 396 | + | <?php else: ?> | |
| 397 | + | <?php acp_empty(t('acp.sord.history_empty'), 'fa-shopping-cart'); ?> | |
| 398 | + | <?php endif; ?> | |
| 399 | + | </div> | |
| 400 | + | </section> | |
| 401 | + | ||
| 402 | + | <!-- ----------------------------------------- Recent points purchases --> | |
| 403 | + | <section class="acp-card"> | |
| 404 | + | <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_points', 'Recent Points Purchases') ?></h2></header> | |
| 405 | + | <div class="acp-card-body is-flush"> | |
| 406 | + | <?php if ($recentPointsPurchases): ?> | |
| 407 | + | <div class="acp-table-wrap"> | |
| 408 | + | <table class="acp-table"> | |
| 409 | + | <thead> | |
| 410 | + | <tr> | |
| 411 | + | <th><?= t('acp.dash.col_account') ?></th> | |
| 412 | + | <th><?= t_default('acp.dash.col_provider', 'Provider') ?></th> | |
| 413 | + | <th class="is-num"><?= t_default('acp.dash.col_amount', 'Amount') ?></th> | |
| 414 | + | <th class="is-num"><?= t('acp.dash.col_points') ?></th> | |
| 415 | + | <th><?= t('acp.sord.col_date') ?></th> | |
| 416 | + | </tr> | |
| 417 | + | </thead> | |
| 418 | + | <tbody> | |
| 419 | + | <?php foreach ($recentPointsPurchases as $row): ?> | |
| 420 | + | <tr> | |
| 421 | + | <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td> | |
| 422 | + | <td><?= h(ucfirst((string)($row['provider'] ?? ''))) ?></td> | |
| 423 | + | <td class="is-num"><?= h(number_format((float)($row['price'] ?? 0), 2, '.', '')) ?> <?= h((string)($row['currency'] ?? '')) ?></td> | |
| 424 | + | <td class="is-num"> | |
| 425 | + | <strong><?= (int)($row['points'] ?? 0) ?></strong> | |
| 426 | + | <?= !empty($row['credited']) ? ' <span class="acp-pill acp-pill--green">'. t_default('acp.dash.credited', 'Credited') .'</span>' : ' <span class="acp-pill acp-pill--amber">'. t_default('acp.dash.pending', 'Pending') .'</span>' ?> | |
| 427 | + | </td> | |
| 428 | + | <td class="is-nowrap is-muted"><?= !empty($row['created_at']) ? h(getClock((int)$row['created_at'], true)) : '—' ?></td> | |
| 429 | + | </tr> | |
| 430 | + | <?php endforeach; ?> | |
| 431 | + | </tbody> | |
| 432 | + | </table> | |
| 433 | + | </div> | |
| 434 | + | <?php else: ?> | |
| 435 | + | <?php acp_empty(t_default('acp.dash.no_points_purchases', 'No point purchases yet.'), 'fa-credit-card'); ?> | |
| 436 | + | <?php endif; ?> | |
| 437 | + | </div> | |
| 438 | + | </section> | |
| 439 | + | ||
| 440 | + | </div> |
| @@ -0,0 +1,167 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Error Log | |
| 4 | + | * Icon: fa-exclamation-triangle | |
| 5 | + | * Group: Overview | |
| 6 | + | * Order: 40 | |
| 7 | + | * Description: Tail the PHP error log - website, plugin and theme errors all land here. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function acp_error_log_ini_path(): string { | |
| 16 | + | $v = @ini_get('error_log'); | |
| 17 | + | return is_string($v) ? trim($v) : ''; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | function acp_error_log_path(): string { | |
| 21 | + | $configured = trim((string) setting('error_log:path', '')); | |
| 22 | + | return $configured !== '' ? $configured : acp_error_log_ini_path(); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** Last $lines lines of a (possibly large) text file, without loading it all into memory. */ | |
| 26 | + | function acp_error_log_tail(string $file, int $lines): array { | |
| 27 | + | $handle = @fopen($file, 'rb'); | |
| 28 | + | if ($handle === false) { | |
| 29 | + | return array(); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | $chunkSize = 8192; | |
| 33 | + | $buffer = ''; | |
| 34 | + | $foundLines = 0; | |
| 35 | + | fseek($handle, 0, SEEK_END); | |
| 36 | + | $pos = ftell($handle); | |
| 37 | + | ||
| 38 | + | while ($pos > 0 && $foundLines <= $lines) { | |
| 39 | + | $read = min($chunkSize, $pos); | |
| 40 | + | $pos -= $read; | |
| 41 | + | fseek($handle, $pos); | |
| 42 | + | $buffer = fread($handle, $read) . $buffer; | |
| 43 | + | $foundLines = substr_count($buffer, "\n"); | |
| 44 | + | } | |
| 45 | + | fclose($handle); | |
| 46 | + | ||
| 47 | + | $rows = explode("\n", rtrim($buffer, "\n")); | |
| 48 | + | $rows = array_slice($rows, -$lines); | |
| 49 | + | return array_reverse($rows); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | function acp_error_log_severity(string $line): string { | |
| 53 | + | if (preg_match('/\b(Fatal error|SQL ERROR|Uncaught|Parse error|ZnoteX DB)\b/i', $line)) { | |
| 54 | + | return 'red'; | |
| 55 | + | } | |
| 56 | + | if (preg_match('/\b(Warning|Deprecated)\b/i', $line)) { | |
| 57 | + | return 'amber'; | |
| 58 | + | } | |
| 59 | + | return ''; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | $module = 'error_log'; | |
| 63 | + | ||
| 64 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 65 | + | $do = (string) ($_POST['do'] ?? ''); | |
| 66 | + | if ($do === 'save_path') { | |
| 67 | + | $newPath = trim((string) ($_POST['path'] ?? '')); | |
| 68 | + | if (setting_set('error_log:path', $newPath)) { | |
| 69 | + | acp_log('error_log.path_save', $newPath); | |
| 70 | + | acp_flash_success(t_default('acp.error_log.saved', 'Log path saved.')); | |
| 71 | + | } else { | |
| 72 | + | acp_flash_error(t_default('acp.error_log.save_failed', 'The log path could not be saved.')); | |
| 73 | + | } | |
| 74 | + | } elseif ($do === 'reset_path') { | |
| 75 | + | if (setting_set('error_log:path', '')) { | |
| 76 | + | acp_log('error_log.path_reset'); | |
| 77 | + | acp_flash_success(t_default('acp.error_log.reset', 'Reverted to the auto-detected path.')); | |
| 78 | + | } else { | |
| 79 | + | acp_flash_error(t_default('acp.error_log.reset_failed', 'The log path could not be reset.')); | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | acp_redirect($module); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | $path = acp_error_log_path(); | |
| 86 | + | $hasFile = $path !== '' && is_file($path) && is_readable($path); | |
| 87 | + | $linesWant = in_array((string) ($_GET['lines'] ?? ''), array('100', '300', '1000'), true) ? (int) $_GET['lines'] : 300; | |
| 88 | + | $rows = $hasFile ? acp_error_log_tail($path, $linesWant) : array(); | |
| 89 | + | $configured = trim((string) setting('error_log:path', '')); | |
| 90 | + | ?> | |
| 91 | + | ||
| 92 | + | <?php if (!$hasFile): ?> | |
| 93 | + | <div class="acp-flash acp-flash--error"> | |
| 94 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 95 | + | <span> | |
| 96 | + | <?php if ($path === ''): ?> | |
| 97 | + | <?= t_default('acp.error_log.not_configured', "PHP is not configured with an <code>error_log</code> file for this request - it is very likely logging to the web server's own error log instead (e.g. Apache's log under XAMPP, WAMP, Uniform Server, or a Linux host), which lives outside this site's folder and cannot be found automatically.") ?> | |
| 98 | + | <?php else: ?> | |
| 99 | + | <?= t_default('acp.error_log.path_missing', 'The configured path <code>{path}</code> does not exist or is not readable from PHP.', ['path' => h($path)]) ?> | |
| 100 | + | <?php endif; ?> | |
| 101 | + | <?= t_default('acp.error_log.help', "Paste the correct path below - check your stack's control panel (XAMPP: <code>apache/logs/error.log</code>, WAMP: <code>logs/apache_error.log</code>, Uniform Server: its <code>logs</code> folder) or your <code>php.ini</code>'s <code>error_log</code> directive.") ?> | |
| 102 | + | </span> | |
| 103 | + | </div> | |
| 104 | + | <?php endif; ?> | |
| 105 | + | ||
| 106 | + | <section class="acp-card"> | |
| 107 | + | <header class="acp-card-head"> | |
| 108 | + | <h2><?= t_default('acp.error_log.title', 'Log file') ?></h2> | |
| 109 | + | <p><?= t_default('acp.error_log.ini_get_label', "PHP's <code>ini_get('error_log')</code> for this request:") ?> <code><?= h(acp_error_log_ini_path() !== '' ? acp_error_log_ini_path() : t_default('acp.error_log.empty_sapi_default', '(empty - using the SAPI default)')) ?></code></p> | |
| 110 | + | </header> | |
| 111 | + | <div class="acp-card-body"> | |
| 112 | + | <form method="post" class="acp-row"> | |
| 113 | + | <?= acp_csrf_field() ?> | |
| 114 | + | <input type="hidden" name="do" value="save_path"> | |
| 115 | + | <div class="acp-field" style="flex:2;min-width:280px;"> | |
| 116 | + | <label class="acp-label" for="path"><?= t_default('acp.error_log.path_label', 'Path to the error log') ?></label> | |
| 117 | + | <input class="acp-input" id="path" name="path" value="<?= h($configured) ?>" placeholder="<?= h(acp_error_log_ini_path()) ?>"> | |
| 118 | + | </div> | |
| 119 | + | <div class="acp-actions"> | |
| 120 | + | <button class="acp-btn" type="submit"><i class="fa fa-check"></i> <?= t_default('acp.error_log.save', 'Save') ?></button> | |
| 121 | + | <?php if ($configured !== ''): ?> | |
| 122 | + | <form method="post" style="display:inline"> | |
| 123 | + | <?= acp_csrf_field() ?> | |
| 124 | + | <input type="hidden" name="do" value="reset_path"> | |
| 125 | + | <button class="acp-btn acp-btn--ghost" type="submit"><?= t_default('acp.error_log.use_auto_detected', 'Use auto-detected path') ?></button> | |
| 126 | + | </form> | |
| 127 | + | <?php endif; ?> | |
| 128 | + | </div> | |
| 129 | + | </form> | |
| 130 | + | </div> | |
| 131 | + | </section> | |
| 132 | + | ||
| 133 | + | <?php if ($hasFile): ?> | |
| 134 | + | <section class="acp-card"> | |
| 135 | + | <header class="acp-card-head"> | |
| 136 | + | <h2><?= t_default('acp.error_log.latest_entries', 'Latest entries') ?></h2> | |
| 137 | + | <p> | |
| 138 | + | <code><?= h($path) ?></code> | |
| 139 | + | · <?= h(number_format(@filesize($path) ?: 0)) ?> <?= t_default('acp.error_log.bytes', 'bytes') ?> | |
| 140 | + | · <?= t_default('acp.error_log.last_modified', 'last modified') ?> <?= h(date('Y-m-d H:i:s', @filemtime($path) ?: time())) ?> | |
| 141 | + | </p> | |
| 142 | + | </header> | |
| 143 | + | <div class="acp-card-body"> | |
| 144 | + | <div class="acp-row" style="margin-bottom:12px"> | |
| 145 | + | <?php foreach (array('100', '300', '1000') as $n): ?> | |
| 146 | + | <a class="acp-btn <?= $linesWant === (int) $n ? '' : 'acp-btn--ghost' ?> acp-btn--sm" href="<?= h(acp_url($module, array('lines' => $n))) ?>"><?= t_default('acp.error_log.last_n', 'Last {n}', ['n' => $n]) ?></a> | |
| 147 | + | <?php endforeach; ?> | |
| 148 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url($module, array('lines' => (string) $linesWant))) ?>"><i class="fa fa-refresh"></i> <?= t_default('acp.error_log.refresh', 'Refresh') ?></a> | |
| 149 | + | </div> | |
| 150 | + | <?php if (!$rows): ?> | |
| 151 | + | <?php acp_empty(t_default('acp.error_log.empty', 'The log file is empty.'), 'fa-file-text-o'); ?> | |
| 152 | + | <?php else: ?> | |
| 153 | + | <div class="acp-table-wrap" style="max-height:70vh;overflow:auto"> | |
| 154 | + | <table class="acp-table"> | |
| 155 | + | <tbody> | |
| 156 | + | <?php foreach ($rows as $line): $sev = acp_error_log_severity($line); ?> | |
| 157 | + | <tr> | |
| 158 | + | <td style="font-family:monospace;font-size:12.5px;white-space:pre-wrap;overflow-wrap:anywhere<?= $sev === 'red' ? ';color:#c0392b' : ($sev === 'amber' ? ';color:#b8860b' : '') ?>"><?= h($line) ?></td> | |
| 159 | + | </tr> | |
| 160 | + | <?php endforeach; ?> | |
| 161 | + | </tbody> | |
| 162 | + | </table> | |
| 163 | + | </div> | |
| 164 | + | <?php endif; ?> | |
| 165 | + | </div> | |
| 166 | + | </section> | |
| 167 | + | <?php endif; ?> |
| @@ -0,0 +1,23 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Feedback Board | |
| 4 | + | * Icon: fa-comments-o | |
| 5 | + | * Group: Support | |
| 6 | + | * Order: 30 | |
| 7 | + | * Description: Forum threads from players awaiting a staff reply. | |
| 8 | + | * Url: ../forum.php?cat=4 | |
| 9 | + | * Target: _self | |
| 10 | + | * | |
| 11 | + | * The Url header turns this into an outbound link: index.php redirects there | |
| 12 | + | * rather than including this file. The badge still comes from | |
| 13 | + | * acp_badge_feedback() like any other module. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | if (!defined('ACP_ROOT')) { | |
| 17 | + | http_response_code(403); | |
| 18 | + | die('Direct access denied.'); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Only reached if the Url header above is removed. | |
| 22 | + | header('Location: ../forum.php?cat=4'); | |
| 23 | + | exit; |
| @@ -0,0 +1,190 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Gallery | |
| 4 | + | * Icon: fa-picture-o | |
| 5 | + | * Group: Content | |
| 6 | + | * Order: 30 | |
| 7 | + | * Description: Moderate player screenshot submissions. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function acp_gallery_rebuild_cache(): void { | |
| 16 | + | $cache = new Cache('engine/cache/gallery'); | |
| 17 | + | $images = fetchImages(2); | |
| 18 | + | ||
| 19 | + | $data = []; | |
| 20 | + | if (is_array($images)) { | |
| 21 | + | foreach ($images as $image) { | |
| 22 | + | $data[] = [ | |
| 23 | + | 'title' => $image['title'] ?? '', | |
| 24 | + | 'desc' => $image['desc'] ?? '', | |
| 25 | + | 'date' => $image['date'] ?? '', | |
| 26 | + | 'image' => $image['image'] ?? '', | |
| 27 | + | ]; | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | $cache->setContent($data); | |
| 32 | + | $cache->save(); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // --------------------------------------------------------------------------- | |
| 36 | + | // Actions | |
| 37 | + | // --------------------------------------------------------------------------- | |
| 38 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 39 | + | ||
| 40 | + | $do = (string)($_POST['do'] ?? ''); | |
| 41 | + | $id = intv($_POST['id'] ?? 0); | |
| 42 | + | ||
| 43 | + | if ($id > 0) { | |
| 44 | + | // Soft delete: hidden from the public gallery but kept on record. | |
| 45 | + | if ($do === 'delete') { | |
| 46 | + | updateImage($id, 3); | |
| 47 | + | acp_gallery_rebuild_cache(); | |
| 48 | + | acp_log('gallery.hide', '#' . $id); | |
| 49 | + | acp_flash_success(t('acp.gal.hidden', ['id' => $id])); | |
| 50 | + | acp_redirect('gallery'); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Accept, or recover a soft-deleted image. | |
| 54 | + | if ($do === 'accept') { | |
| 55 | + | updateImage($id, 2); | |
| 56 | + | acp_gallery_rebuild_cache(); | |
| 57 | + | acp_log('gallery.accept', '#' . $id); | |
| 58 | + | acp_flash_success(t('acp.gal.public', ['id' => $id])); | |
| 59 | + | acp_redirect('gallery'); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Hard delete: drop it at imgur too, then remove the row. | |
| 63 | + | if ($do === 'remove') { | |
| 64 | + | $delhash = (string)($_POST['delhash'] ?? ''); | |
| 65 | + | $imgurClientID = (string)($config['gallery']['Client ID'] ?? ''); | |
| 66 | + | ||
| 67 | + | if ($delhash !== '' && $imgurClientID !== '' && function_exists('curl_init')) { | |
| 68 | + | $ch = curl_init(); | |
| 69 | + | curl_setopt($ch, CURLOPT_URL, 'https://api.imgur.com/3/image/' . rawurlencode($delhash)); | |
| 70 | + | curl_setopt($ch, CURLOPT_HEADER, false); | |
| 71 | + | curl_setopt($ch, CURLOPT_POST, true); | |
| 72 | + | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
| 73 | + | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); | |
| 74 | + | curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Client-ID {$imgurClientID}"]); | |
| 75 | + | ||
| 76 | + | $result = curl_exec($ch); | |
| 77 | + | if ($result === false) { | |
| 78 | + | error_log('cURL error (imgur delete): ' . curl_error($ch)); | |
| 79 | + | } | |
| 80 | + | curl_close($ch); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | db()->execute("DELETE FROM `znote_images` WHERE `id` = ? LIMIT 1;", [$id]); | |
| 84 | + | acp_gallery_rebuild_cache(); | |
| 85 | + | acp_log('gallery.remove', '#' . $id); | |
| 86 | + | acp_flash_success(t('acp.gal.removed', ['id' => $id])); | |
| 87 | + | acp_redirect('gallery'); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | acp_flash_error(t('acp.gal.unknown_action')); | |
| 92 | + | acp_redirect('gallery'); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * One moderation section: heading, count, and a card per image. | |
| 97 | + | * | |
| 98 | + | * @param array<int, array<string, mixed>>|false $images | |
| 99 | + | * @param array<int, array{do: string, label: string, icon: string, class: string, confirm?: string}> $actions | |
| 100 | + | */ | |
| 101 | + | function acp_gallery_section($images, string $title, string $subtitle, array $actions, string $emptyText): void { | |
| 102 | + | $images = is_array($images) ? $images : []; | |
| 103 | + | ?> | |
| 104 | + | <section class="acp-card"> | |
| 105 | + | <header class="acp-card-head"> | |
| 106 | + | <h2><?= h($title) ?></h2> | |
| 107 | + | <p><?= h($subtitle) ?></p> | |
| 108 | + | <span class="acp-pill acp-pill--grey"><?= count($images) ?></span> | |
| 109 | + | </header> | |
| 110 | + | <div class="acp-card-body"> | |
| 111 | + | <?php if (!$images): ?> | |
| 112 | + | <?php acp_empty($emptyText, 'fa-picture-o'); ?> | |
| 113 | + | <?php else: ?> | |
| 114 | + | <div class="acp-media"> | |
| 115 | + | <?php foreach ($images as $image): | |
| 116 | + | $id = (int)($image['id'] ?? 0); | |
| 117 | + | $imageUrl = (string)($image['image'] ?? ''); | |
| 118 | + | $delhash = (string)($image['delhash'] ?? ''); | |
| 119 | + | $desc = str_replace(['\\r', '\\n'], ['', '<br>'], h((string)($image['desc'] ?? ''))); | |
| 120 | + | ?> | |
| 121 | + | <article class="acp-media-item"> | |
| 122 | + | <a href="<?= h($imageUrl) ?>" target="_blank" rel="noopener"> | |
| 123 | + | <img src="<?= h($imageUrl) ?>" alt="<?= h((string)($image['title'] ?? '')) ?>" loading="lazy"> | |
| 124 | + | </a> | |
| 125 | + | <div class="acp-media-body"> | |
| 126 | + | <h3><?= h((string)($image['title'] ?? '')) ?></h3> | |
| 127 | + | <p><?= $desc ?></p> | |
| 128 | + | </div> | |
| 129 | + | <div class="acp-media-foot"> | |
| 130 | + | <?php foreach ($actions as $action): ?> | |
| 131 | + | <form class="acp-inline-form" method="post" | |
| 132 | + | <?= isset($action['confirm']) ? 'data-confirm="' . h($action['confirm']) . '"' : '' ?>> | |
| 133 | + | <?= acp_csrf_field() ?> | |
| 134 | + | <input type="hidden" name="do" value="<?= h($action['do']) ?>"> | |
| 135 | + | <input type="hidden" name="id" value="<?= $id ?>"> | |
| 136 | + | <?php if ($action['do'] === 'remove'): ?> | |
| 137 | + | <input type="hidden" name="delhash" value="<?= h($delhash) ?>"> | |
| 138 | + | <?php endif; ?> | |
| 139 | + | <button class="acp-btn <?= h($action['class']) ?> acp-btn--sm" type="submit"> | |
| 140 | + | <i class="fa <?= h($action['icon']) ?>"></i> <?= h($action['label']) ?> | |
| 141 | + | </button> | |
| 142 | + | </form> | |
| 143 | + | <?php endforeach; ?> | |
| 144 | + | </div> | |
| 145 | + | </article> | |
| 146 | + | <?php endforeach; ?> | |
| 147 | + | </div> | |
| 148 | + | <?php endif; ?> | |
| 149 | + | </div> | |
| 150 | + | </section> | |
| 151 | + | <?php | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | acp_gallery_section( | |
| 155 | + | fetchImages(1), | |
| 156 | + | t('acp.gal.pending_title'), | |
| 157 | + | t('acp.gal.pending_sub'), | |
| 158 | + | [ | |
| 159 | + | ['do' => 'accept', 'label' => t('acp.gal.approve'), 'icon' => 'fa-check', 'class' => 'acp-btn--green'], | |
| 160 | + | ['do' => 'delete', 'label' => t('acp.gal.reject'), 'icon' => 'fa-times', 'class' => 'acp-btn--red'], | |
| 161 | + | ], | |
| 162 | + | t('acp.gal.pending_empty') | |
| 163 | + | ); | |
| 164 | + | ||
| 165 | + | acp_gallery_section( | |
| 166 | + | fetchImages(2), | |
| 167 | + | t('acp.gal.public_title'), | |
| 168 | + | t('acp.gal.public_sub'), | |
| 169 | + | [ | |
| 170 | + | ['do' => 'delete', 'label' => t('acp.gal.hide'), 'icon' => 'fa-eye-slash', 'class' => 'acp-btn--amber'], | |
| 171 | + | ], | |
| 172 | + | t('acp.gal.public_empty') | |
| 173 | + | ); | |
| 174 | + | ||
| 175 | + | acp_gallery_section( | |
| 176 | + | fetchImages(3), | |
| 177 | + | t('acp.gal.hidden_title'), | |
| 178 | + | t('acp.gal.hidden_sub'), | |
| 179 | + | [ | |
| 180 | + | ['do' => 'accept', 'label' => t('acp.gal.recover'), 'icon' => 'fa-undo', 'class' => 'acp-btn--green'], | |
| 181 | + | [ | |
| 182 | + | 'do' => 'remove', | |
| 183 | + | 'label' => t('acp.gal.delete_forever'), | |
| 184 | + | 'icon' => 'fa-trash', | |
| 185 | + | 'class' => 'acp-btn--red', | |
| 186 | + | 'confirm' => t('acp.gal.delete_confirm'), | |
| 187 | + | ], | |
| 188 | + | ], | |
| 189 | + | t('acp.gal.hidden_empty') | |
| 190 | + | ); |
| @@ -0,0 +1,160 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: System Health | |
| 4 | + | * Icon: fa-heartbeat | |
| 5 | + | * Group: Operations | |
| 6 | + | * Order: 10 | |
| 7 | + | * Description: PHP, database, cache, disk and migrations at a glance. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function acp_health_bytes(int $bytes): string { | |
| 16 | + | if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2) . ' GB'; | |
| 17 | + | if ($bytes >= 1048576) return number_format($bytes / 1048576, 1) . ' MB'; | |
| 18 | + | if ($bytes >= 1024) return number_format($bytes / 1024, 1) . ' KB'; | |
| 19 | + | return $bytes . ' B'; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // --- PHP --------------------------------------------------------------- | |
| 23 | + | $phpChecks = array( | |
| 24 | + | array('PHP version', PHP_VERSION_ID >= 80100, PHP_VERSION), | |
| 25 | + | ); | |
| 26 | + | foreach (array('mysqli' => true, 'curl' => false, 'openssl' => false, 'gd' => false, 'zip' => false, 'mbstring' => false) as $ext => $required) { | |
| 27 | + | $phpChecks[] = array( | |
| 28 | + | 'Extension: ' . $ext, | |
| 29 | + | extension_loaded($ext), | |
| 30 | + | extension_loaded($ext) ? 'Loaded' : ($required ? 'Missing (required)' : 'Missing (optional)'), | |
| 31 | + | ); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // --- Database ------------------------------------------------------------ | |
| 35 | + | $dbSize = db()->fetchOne(" | |
| 36 | + | SELECT | |
| 37 | + | ROUND(SUM(data_length + index_length)) AS `bytes`, | |
| 38 | + | COUNT(*) AS `tables` | |
| 39 | + | FROM `information_schema`.`tables` | |
| 40 | + | WHERE `table_schema` = DATABASE(); | |
| 41 | + | "); | |
| 42 | + | $dbBytes = (int)($dbSize['bytes'] ?? 0); | |
| 43 | + | $dbTables = (int)($dbSize['tables'] ?? 0); | |
| 44 | + | ||
| 45 | + | // --- Cache ----------------------------------------------------------- | |
| 46 | + | $cacheStats = function_exists('znote_cache_stats') ? znote_cache_stats() : array('files' => 0, 'bytes' => 0, 'apcu_available' => false); | |
| 47 | + | ||
| 48 | + | // --- Migrations -------------------------------------------------------- | |
| 49 | + | $migrationStatus = function_exists('znote_migrations_status') ? znote_migrations_status() : array(); | |
| 50 | + | $pendingMigrations = 0; | |
| 51 | + | foreach ($migrationStatus as $m) { | |
| 52 | + | if (($m['state'] ?? '') === 'pending') $pendingMigrations++; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // --- Disk -------------------------------------------------------------- | |
| 56 | + | $diskFree = @disk_free_space(dirname(__DIR__, 2)); | |
| 57 | + | $diskTotal = @disk_total_space(dirname(__DIR__, 2)); | |
| 58 | + | ||
| 59 | + | // --- Error log ----------------------------------------------------------- | |
| 60 | + | $errorLogPath = trim((string)(setting('error_log:path', '') ?: (string)@ini_get('error_log'))); | |
| 61 | + | $errorLogSize = ($errorLogPath !== '' && is_file($errorLogPath)) ? (int)filesize($errorLogPath) : null; | |
| 62 | + | $errorLogModified = ($errorLogPath !== '' && is_file($errorLogPath)) ? (int)filemtime($errorLogPath) : null; | |
| 63 | + | ||
| 64 | + | // --- Backups ------------------------------------------------------------- | |
| 65 | + | $backups = function_exists('znote_backups_list') ? znote_backups_list() : array(); | |
| 66 | + | $lastBackup = $backups[0] ?? null; | |
| 67 | + | ?> | |
| 68 | + | ||
| 69 | + | <div class="acp-stats"> | |
| 70 | + | <?php | |
| 71 | + | acp_stat(t_default('acp.health.stat_db_size', 'Database size'), acp_health_bytes($dbBytes), 'fa-database', null, 'blue'); | |
| 72 | + | acp_stat(t_default('acp.health.stat_cache', 'Cache entries'), number_format((int)($cacheStats['files'] ?? 0)), 'fa-bolt', acp_url('settings'), 'purple'); | |
| 73 | + | acp_stat(t_default('acp.health.stat_migrations', 'Pending migrations'), $pendingMigrations, 'fa-database', acp_url('migrations'), $pendingMigrations > 0 ? 'amber' : 'green'); | |
| 74 | + | acp_stat(t_default('acp.health.stat_backup', 'Last backup'), $lastBackup ? h(getClock((int)$lastBackup['time'], true)) : t_default('acp.health.no_backup', 'None yet'), 'fa-archive', acp_url('backups'), $lastBackup ? 'green' : 'amber'); | |
| 75 | + | ?> | |
| 76 | + | </div> | |
| 77 | + | ||
| 78 | + | <div class="acp-grid acp-grid--2"> | |
| 79 | + | ||
| 80 | + | <section class="acp-card"> | |
| 81 | + | <header class="acp-card-head"> | |
| 82 | + | <h2><?= t_default('acp.health.php_title', 'PHP & extensions') ?></h2> | |
| 83 | + | </header> | |
| 84 | + | <div class="acp-card-body is-flush"> | |
| 85 | + | <div class="acp-table-wrap"> | |
| 86 | + | <table class="acp-table"> | |
| 87 | + | <tbody> | |
| 88 | + | <?php foreach ($phpChecks as $check): ?> | |
| 89 | + | <tr> | |
| 90 | + | <td><?= h($check[0]) ?></td> | |
| 91 | + | <td class="is-num"> | |
| 92 | + | <span class="acp-pill acp-pill--<?= $check[1] ? 'green' : 'amber' ?>"><?= h($check[2]) ?></span> | |
| 93 | + | </td> | |
| 94 | + | </tr> | |
| 95 | + | <?php endforeach; ?> | |
| 96 | + | </tbody> | |
| 97 | + | </table> | |
| 98 | + | </div> | |
| 99 | + | </div> | |
| 100 | + | </section> | |
| 101 | + | ||
| 102 | + | <section class="acp-card"> | |
| 103 | + | <header class="acp-card-head"> | |
| 104 | + | <h2><?= t_default('acp.health.storage_title', 'Storage') ?></h2> | |
| 105 | + | </header> | |
| 106 | + | <div class="acp-card-body is-flush"> | |
| 107 | + | <div class="acp-table-wrap"> | |
| 108 | + | <table class="acp-table"> | |
| 109 | + | <tbody> | |
| 110 | + | <tr> | |
| 111 | + | <td><?= t_default('acp.health.db_tables', 'Database tables') ?></td> | |
| 112 | + | <td class="is-num"><?= number_format($dbTables) ?></td> | |
| 113 | + | </tr> | |
| 114 | + | <tr> | |
| 115 | + | <td><?= t_default('acp.health.cache_size', 'Cache on disk') ?></td> | |
| 116 | + | <td class="is-num"><?= h(acp_health_bytes((int)($cacheStats['bytes'] ?? 0))) ?></td> | |
| 117 | + | </tr> | |
| 118 | + | <tr> | |
| 119 | + | <td><?= t_default('acp.health.apcu', 'APCu memory cache') ?></td> | |
| 120 | + | <td class="is-num"> | |
| 121 | + | <span class="acp-pill acp-pill--<?= !empty($cacheStats['apcu_available']) ? 'green' : 'grey' ?>"> | |
| 122 | + | <?= !empty($cacheStats['apcu_available']) ? t_default('acp.health.available', 'Available') : t_default('acp.health.unavailable', 'Unavailable') ?> | |
| 123 | + | </span> | |
| 124 | + | </td> | |
| 125 | + | </tr> | |
| 126 | + | <?php if ($diskFree !== false && $diskTotal !== false && $diskTotal > 0): ?> | |
| 127 | + | <tr> | |
| 128 | + | <td><?= t_default('acp.health.disk_free', 'Disk free') ?></td> | |
| 129 | + | <td class="is-num"><?= h(acp_health_bytes((int)$diskFree)) ?> / <?= h(acp_health_bytes((int)$diskTotal)) ?></td> | |
| 130 | + | </tr> | |
| 131 | + | <?php endif; ?> | |
| 132 | + | </tbody> | |
| 133 | + | </table> | |
| 134 | + | </div> | |
| 135 | + | </div> | |
| 136 | + | </section> | |
| 137 | + | ||
| 138 | + | </div> | |
| 139 | + | ||
| 140 | + | <section class="acp-card"> | |
| 141 | + | <header class="acp-card-head"> | |
| 142 | + | <h2><?= t_default('acp.health.errorlog_title', 'Error log') ?></h2> | |
| 143 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('error_log')) ?>" style="margin-left:auto;"> | |
| 144 | + | <?= t_default('acp.health.errorlog_open', 'Open') ?> | |
| 145 | + | </a> | |
| 146 | + | </header> | |
| 147 | + | <div class="acp-card-body"> | |
| 148 | + | <?php if ($errorLogSize !== null): ?> | |
| 149 | + | <p> | |
| 150 | + | <?= t_default('acp.health.errorlog_summary', '{path} - {size}, last written {when}.', [ | |
| 151 | + | 'path' => '<code>' . h($errorLogPath) . '</code>', | |
| 152 | + | 'size' => h(acp_health_bytes($errorLogSize)), | |
| 153 | + | 'when' => h(getClock((int)$errorLogModified, true)), | |
| 154 | + | ]) ?> | |
| 155 | + | </p> | |
| 156 | + | <?php else: ?> | |
| 157 | + | <p class="is-muted"><?= t_default('acp.health.errorlog_none', 'No log file found at the configured or auto-detected path.') ?></p> | |
| 158 | + | <?php endif; ?> | |
| 159 | + | </div> | |
| 160 | + | </section> |
| @@ -0,0 +1,241 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Helpdesk | |
| 4 | + | * Icon: fa-life-ring | |
| 5 | + | * Group: Support | |
| 6 | + | * Order: 20 | |
| 7 | + | * Description: Answer, close and delete player support tickets. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | $view = intv($_GET['view'] ?? 0); | |
| 16 | + | ||
| 17 | + | // --------------------------------------------------------------------------- | |
| 18 | + | // Ticket actions | |
| 19 | + | // --------------------------------------------------------------------------- | |
| 20 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && $view > 0) { | |
| 21 | + | ||
| 22 | + | // ----------------------------------------------------------- Post reply | |
| 23 | + | if (!empty($_POST['reply_text'])) { | |
| 24 | + | $replyText = sanitize((string)$_POST['reply_text']); | |
| 25 | + | $username = sanitize((string)($_POST['username'] ?? 'ADMIN')); | |
| 26 | + | ||
| 27 | + | db()->execute(" | |
| 28 | + | INSERT INTO `znote_tickets_replies` (`tid`, `username`, `message`, `created`) | |
| 29 | + | VALUES (?, ?, ?, ?); | |
| 30 | + | ", [$view, $username, $replyText, time()]); | |
| 31 | + | ||
| 32 | + | db()->execute(" | |
| 33 | + | UPDATE `znote_tickets` | |
| 34 | + | SET `status` = 'Staff-Reply' | |
| 35 | + | WHERE `id` = ? | |
| 36 | + | LIMIT 1; | |
| 37 | + | ", [$view]); | |
| 38 | + | ||
| 39 | + | acp_log('helpdesk.reply', '#' . $view); | |
| 40 | + | acp_flash_success(t('acp.hd.reply_posted')); | |
| 41 | + | acp_redirect('helpdesk', ['view' => $view]); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | $ticketId = intv($_POST['admin_ticket_id'] ?? 0); | |
| 45 | + | ||
| 46 | + | // ---------------------------------------------------------- Close ticket | |
| 47 | + | if (!empty($_POST['admin_ticket_close']) && $ticketId > 0) { | |
| 48 | + | db()->execute("UPDATE `znote_tickets` SET `status` = 'CLOSED' WHERE `id` = ? LIMIT 1;", [$ticketId]); | |
| 49 | + | acp_log('helpdesk.close', '#' . $ticketId); | |
| 50 | + | acp_flash_success(t('acp.hd.closed')); | |
| 51 | + | acp_redirect('helpdesk', ['view' => $view]); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // ----------------------------------------------------------- Open ticket | |
| 55 | + | if (!empty($_POST['admin_ticket_open']) && $ticketId > 0) { | |
| 56 | + | db()->execute("UPDATE `znote_tickets` SET `status` = 'Open' WHERE `id` = ? LIMIT 1;", [$ticketId]); | |
| 57 | + | acp_log('helpdesk.reopen', '#' . $ticketId); | |
| 58 | + | acp_flash_success(t('acp.hd.reopened')); | |
| 59 | + | acp_redirect('helpdesk', ['view' => $view]); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // --------------------------------------------------------- Delete ticket | |
| 63 | + | if (!empty($_POST['admin_ticket_delete']) && $ticketId > 0) { | |
| 64 | + | db()->execute("DELETE FROM `znote_tickets` WHERE `id` = ? LIMIT 1;", [$ticketId]); | |
| 65 | + | db()->execute("DELETE FROM `znote_tickets_replies` WHERE `tid` = ?;", [$ticketId]); | |
| 66 | + | acp_log('helpdesk.delete', '#' . $ticketId); | |
| 67 | + | acp_flash_success(t('acp.hd.deleted', ['id' => $ticketId])); | |
| 68 | + | acp_redirect('helpdesk'); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Pill colour per ticket status. | |
| 73 | + | function acp_ticket_tone(string $status): string { | |
| 74 | + | switch (strtoupper($status)) { | |
| 75 | + | case 'CLOSED': return 'grey'; | |
| 76 | + | case 'STAFF-REPLY': return 'blue'; | |
| 77 | + | case 'OPEN': return 'green'; | |
| 78 | + | default: return 'amber'; | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ?> | |
| 82 | + | ||
| 83 | + | <?php if ($view > 0): | |
| 84 | + | ||
| 85 | + | $ticket = db()->fetchOne("SELECT * FROM `znote_tickets` WHERE `id` = ? LIMIT 1;", [$view]); | |
| 86 | + | ||
| 87 | + | if (!is_array($ticket)): | |
| 88 | + | ?> | |
| 89 | + | <section class="acp-card"> | |
| 90 | + | <div class="acp-card-body"> | |
| 91 | + | <?php acp_empty(t('acp.hd.not_found'), 'fa-question-circle-o'); ?> | |
| 92 | + | <div class="acp-actions" style="justify-content:center;"> | |
| 93 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('helpdesk')) ?>"><?= t('acp.hd.back_all') ?></a> | |
| 94 | + | </div> | |
| 95 | + | </div> | |
| 96 | + | </section> | |
| 97 | + | <?php | |
| 98 | + | else: | |
| 99 | + | $ticketId = (int)($ticket['id'] ?? $view); | |
| 100 | + | $status = (string)($ticket['status'] ?? ''); | |
| 101 | + | $isClosed = (strtoupper($status) === 'CLOSED'); | |
| 102 | + | ||
| 103 | + | $replies = db()->fetchAll(" | |
| 104 | + | SELECT * FROM `znote_tickets_replies` | |
| 105 | + | WHERE `tid` = ? | |
| 106 | + | ORDER BY `created` ASC; | |
| 107 | + | ", [$view]); | |
| 108 | + | ?> | |
| 109 | + | ||
| 110 | + | <div class="acp-toolbar"> | |
| 111 | + | <div> | |
| 112 | + | <strong><?= t('acp.hd.ticket_hash', ['id' => $ticketId]) ?></strong> | |
| 113 | + | — <?= h((string)($ticket['subject'] ?? '')) ?> | |
| 114 | + | <span class="acp-pill acp-pill--<?= h(acp_ticket_tone($status)) ?>"><?= h($status) ?></span> | |
| 115 | + | </div> | |
| 116 | + | <div class="acp-actions is-tight"> | |
| 117 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('helpdesk')) ?>"> | |
| 118 | + | <i class="fa fa-arrow-left"></i> <?= t('acp.hd.all_tickets') ?> | |
| 119 | + | </a> | |
| 120 | + | <form class="acp-inline-form" method="post"> | |
| 121 | + | <?= acp_csrf_field() ?> | |
| 122 | + | <input type="hidden" name="admin_ticket_id" value="<?= $ticketId ?>"> | |
| 123 | + | <?php if (!$isClosed): ?> | |
| 124 | + | <button class="acp-btn acp-btn--amber acp-btn--sm" type="submit" name="admin_ticket_close" value="1"> | |
| 125 | + | <i class="fa fa-lock"></i> <?= t('acp.hd.close') ?> | |
| 126 | + | </button> | |
| 127 | + | <?php else: ?> | |
| 128 | + | <button class="acp-btn acp-btn--green acp-btn--sm" type="submit" name="admin_ticket_open" value="1"> | |
| 129 | + | <i class="fa fa-unlock"></i> <?= t('acp.hd.reopen') ?> | |
| 130 | + | </button> | |
| 131 | + | <?php endif; ?> | |
| 132 | + | </form> | |
| 133 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.hd.confirm_delete')) ?>"> | |
| 134 | + | <?= acp_csrf_field() ?> | |
| 135 | + | <input type="hidden" name="admin_ticket_id" value="<?= $ticketId ?>"> | |
| 136 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit" name="admin_ticket_delete" value="1"> | |
| 137 | + | <i class="fa fa-trash"></i> <?= t('acp.hd.delete') ?> | |
| 138 | + | </button> | |
| 139 | + | </form> | |
| 140 | + | </div> | |
| 141 | + | </div> | |
| 142 | + | ||
| 143 | + | <article class="acp-post"> | |
| 144 | + | <header class="acp-post-head"> | |
| 145 | + | <span><?= t('acp.hd.opened_by') ?> <strong><?= h((string)($ticket['username'] ?? '')) ?></strong></span> | |
| 146 | + | <span><?= h(getClock((int)($ticket['creation'] ?? 0), true)) ?></span> | |
| 147 | + | </header> | |
| 148 | + | <div class="acp-post-body"><?= nl2br(h((string)($ticket['message'] ?? ''))) ?></div> | |
| 149 | + | </article> | |
| 150 | + | ||
| 151 | + | <?php if (is_array($replies) && $replies): ?> | |
| 152 | + | <?php foreach ($replies as $reply): | |
| 153 | + | $replyUser = (string)($reply['username'] ?? ''); | |
| 154 | + | $isStaff = (strtoupper($replyUser) === 'ADMIN'); | |
| 155 | + | ?> | |
| 156 | + | <article class="acp-post<?= $isStaff ? ' acp-post--staff' : '' ?>"> | |
| 157 | + | <header class="acp-post-head"> | |
| 158 | + | <span><?= $isStaff ? '<i class="fa fa-shield"></i> ' : '' ?><?= t('acp.hd.reply_by') ?> <strong><?= h($replyUser) ?></strong></span> | |
| 159 | + | <span><?= h(getClock((int)($reply['created'] ?? 0), true)) ?></span> | |
| 160 | + | </header> | |
| 161 | + | <div class="acp-post-body"><?= nl2br(h((string)($reply['message'] ?? ''))) ?></div> | |
| 162 | + | </article> | |
| 163 | + | <?php endforeach; ?> | |
| 164 | + | <?php endif; ?> | |
| 165 | + | ||
| 166 | + | <?php if (!$isClosed): ?> | |
| 167 | + | <section class="acp-card"> | |
| 168 | + | <header class="acp-card-head"><h2><?= t('acp.hd.reply_heading') ?></h2></header> | |
| 169 | + | <div class="acp-card-body"> | |
| 170 | + | <form method="post"> | |
| 171 | + | <?= acp_csrf_field() ?> | |
| 172 | + | <input type="hidden" name="username" value="ADMIN"> | |
| 173 | + | <div class="acp-field"> | |
| 174 | + | <textarea class="acp-textarea" name="reply_text" rows="7" placeholder="<?= h(t('acp.hd.reply_placeholder')) ?>" required></textarea> | |
| 175 | + | </div> | |
| 176 | + | <div class="acp-actions"> | |
| 177 | + | <button class="acp-btn" type="submit"><i class="fa fa-reply"></i> <?= t('acp.hd.post_reply') ?></button> | |
| 178 | + | </div> | |
| 179 | + | </form> | |
| 180 | + | </div> | |
| 181 | + | </section> | |
| 182 | + | <?php else: ?> | |
| 183 | + | <section class="acp-card"> | |
| 184 | + | <div class="acp-card-body"> | |
| 185 | + | <?php acp_empty(t('acp.hd.closed_hint'), 'fa-lock'); ?> | |
| 186 | + | </div> | |
| 187 | + | </section> | |
| 188 | + | <?php endif; ?> | |
| 189 | + | ||
| 190 | + | <?php endif; ?> | |
| 191 | + | ||
| 192 | + | <?php else: | |
| 193 | + | ||
| 194 | + | $tickets = db()->fetchAll(" | |
| 195 | + | SELECT `id`, `subject`, `username`, `creation`, `status` | |
| 196 | + | FROM `znote_tickets` | |
| 197 | + | ORDER BY `creation` DESC; | |
| 198 | + | "); | |
| 199 | + | ?> | |
| 200 | + | ||
| 201 | + | <section class="acp-card"> | |
| 202 | + | <header class="acp-card-head"> | |
| 203 | + | <h2><?= t('acp.hd.all_tickets') ?></h2> | |
| 204 | + | <p><?= t('acp.hd.newest_first') ?></p> | |
| 205 | + | </header> | |
| 206 | + | <div class="acp-card-body is-flush"> | |
| 207 | + | <?php if (is_array($tickets) && $tickets): ?> | |
| 208 | + | <div class="acp-table-wrap"> | |
| 209 | + | <table class="acp-table"> | |
| 210 | + | <thead> | |
| 211 | + | <tr> | |
| 212 | + | <th>#</th> | |
| 213 | + | <th><?= t('acp.hd.col_subject') ?></th> | |
| 214 | + | <th><?= t('acp.hd.col_opened_by') ?></th> | |
| 215 | + | <th><?= t('acp.hd.col_created') ?></th> | |
| 216 | + | <th><?= t('acp.hd.col_status') ?></th> | |
| 217 | + | </tr> | |
| 218 | + | </thead> | |
| 219 | + | <tbody> | |
| 220 | + | <?php foreach ($tickets as $ticket): | |
| 221 | + | $id = (int)($ticket['id'] ?? 0); | |
| 222 | + | $status = (string)($ticket['status'] ?? ''); | |
| 223 | + | ?> | |
| 224 | + | <tr> | |
| 225 | + | <td class="is-muted"><?= $id ?></td> | |
| 226 | + | <td><a href="<?= h(acp_url('helpdesk', ['view' => $id])) ?>"><?= h((string)($ticket['subject'] ?? '')) ?></a></td> | |
| 227 | + | <td><?= h((string)($ticket['username'] ?? '')) ?></td> | |
| 228 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)($ticket['creation'] ?? 0), true)) ?></td> | |
| 229 | + | <td><span class="acp-pill acp-pill--<?= h(acp_ticket_tone($status)) ?>"><?= h($status) ?></span></td> | |
| 230 | + | </tr> | |
| 231 | + | <?php endforeach; ?> | |
| 232 | + | </tbody> | |
| 233 | + | </table> | |
| 234 | + | </div> | |
| 235 | + | <?php else: ?> | |
| 236 | + | <?php acp_empty(t('acp.hd.empty'), 'fa-life-ring'); ?> | |
| 237 | + | <?php endif; ?> | |
| 238 | + | </div> | |
| 239 | + | </section> | |
| 240 | + | ||
| 241 | + | <?php endif; ?> |
| @@ -0,0 +1,220 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Items | |
| 4 | + | * Icon: fa-shield | |
| 5 | + | * Group: Server Info | |
| 6 | + | * Order: 12 | |
| 7 | + | * Description: Add or edit a single item without re-uploading the whole items.xml. | |
| 8 | + | * Hidden: true | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | ||
| 12 | + | if (!defined('ACP_ROOT')) { | |
| 13 | + | http_response_code(403); | |
| 14 | + | die('Direct access denied.'); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | const ITMED_TYPE = 'item'; | |
| 18 | + | ||
| 19 | + | /** Stored attribute map -> editable {key, value} rows. */ | |
| 20 | + | function itmed_attr_rows(array $attributes): array { | |
| 21 | + | $rows = array(); | |
| 22 | + | foreach ($attributes as $key => $value) { | |
| 23 | + | $rows[] = array('key' => (string)$key, 'value' => is_scalar($value) ? (string)$value : ''); | |
| 24 | + | } | |
| 25 | + | return $rows; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** Posted {key, value} rows -> attribute map, blank/duplicate keys dropped. */ | |
| 29 | + | function itmed_attr_map_from_post(array $rowsRaw): array { | |
| 30 | + | $map = array(); | |
| 31 | + | foreach ($rowsRaw as $row) { | |
| 32 | + | if (!is_array($row)) continue; | |
| 33 | + | $key = trim((string)($row['key'] ?? '')); | |
| 34 | + | if ($key === '') continue; | |
| 35 | + | $map[$key] = trim((string)($row['value'] ?? '')); | |
| 36 | + | } | |
| 37 | + | return $map; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | $items = serverdata_load('items'); | |
| 41 | + | $items = is_array($items) ? $items : array(); | |
| 42 | + | ||
| 43 | + | if (!$items && !serverdata_override_table_exists()) { | |
| 44 | + | acp_flash_error(t_default('acp.itmed.no_data', 'Upload an items.xml on Server Info first - there is nothing published to edit yet.')); | |
| 45 | + | acp_redirect('serverinfo'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 49 | + | $adminName = (string)($GLOBALS['user_data']['name'] ?? ''); | |
| 50 | + | $action = (string)($_POST['itmed_action'] ?? ''); | |
| 51 | + | ||
| 52 | + | if ($action === 'save') { | |
| 53 | + | $id = intv($_POST['id'] ?? 0); | |
| 54 | + | $name = trim((string)($_POST['name'] ?? '')); | |
| 55 | + | ||
| 56 | + | if ($id <= 0 || $name === '') { | |
| 57 | + | acp_flash_error(t_default('acp.itmed.missing_fields', 'ID and name are required.')); | |
| 58 | + | acp_redirect('items_editor'); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | $record = array( | |
| 62 | + | 'id' => (string)$id, | |
| 63 | + | 'name' => $name, | |
| 64 | + | 'attributes' => itmed_attr_map_from_post((array)($_POST['attr'] ?? array())), | |
| 65 | + | ); | |
| 66 | + | ||
| 67 | + | if (serverdata_override_set('items', ITMED_TYPE . ':' . $id, $record, $adminName)) { | |
| 68 | + | acp_log('serverdata.item_save', $name, ['type' => ITMED_TYPE, 'id' => $id]); | |
| 69 | + | acp_flash_success(t_default('acp.itmed.saved', 'Item #{id} saved.', ['id' => $id])); | |
| 70 | + | } else { | |
| 71 | + | acp_flash_error(t_default('acp.itmed.save_failed', 'Could not save - run the pending database migration first (Admin Panel > Migrations).')); | |
| 72 | + | } | |
| 73 | + | acp_redirect('items_editor'); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if ($action === 'delete') { | |
| 77 | + | $id = intv($_POST['id'] ?? 0); | |
| 78 | + | if (serverdata_override_delete('items', ITMED_TYPE . ':' . $id, $adminName)) { | |
| 79 | + | acp_log('serverdata.item_delete', '#' . $id, ['type' => ITMED_TYPE, 'id' => $id]); | |
| 80 | + | acp_flash_success(t_default('acp.itmed.removed', 'Item #{id} removed.', ['id' => $id])); | |
| 81 | + | } else { | |
| 82 | + | acp_flash_error(t_default('acp.itmed.remove_failed', 'Could not remove that item.')); | |
| 83 | + | } | |
| 84 | + | acp_redirect('items_editor'); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | $editId = intv($_GET['edit'] ?? 0); | |
| 89 | + | $editing = null; | |
| 90 | + | if ($editId > 0 && isset($items[ITMED_TYPE][(string)$editId])) { | |
| 91 | + | $editing = $items[ITMED_TYPE][(string)$editId]; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | $attrRows = itmed_attr_rows($editing['attributes'] ?? array()); | |
| 95 | + | ||
| 96 | + | $attrColumns = array( | |
| 97 | + | 'key' => array('label' => t_default('acp.itmed.col_attr_key', 'Attribute'), 'type' => 'text'), | |
| 98 | + | 'value' => array('label' => t_default('acp.itmed.col_attr_value', 'Value'), 'type' => 'text'), | |
| 99 | + | ); | |
| 100 | + | ||
| 101 | + | $list = $items[ITMED_TYPE] ?? array(); | |
| 102 | + | ksort($list, SORT_NUMERIC); | |
| 103 | + | ?> | |
| 104 | + | ||
| 105 | + | <section class="acp-card"> | |
| 106 | + | <header class="acp-card-head"> | |
| 107 | + | <h2><?= $editing ? h(t_default('acp.itmed.edit_title', 'Edit item #{id}', ['id' => $editId])) : h(t_default('acp.itmed.add_title', 'Add an item')) ?></h2> | |
| 108 | + | <p><?= h(t_default('acp.itmed.sub', 'Saved separately from items.xml - a later re-upload will not overwrite this.')) ?></p> | |
| 109 | + | </header> | |
| 110 | + | <div class="acp-card-body"> | |
| 111 | + | <form method="post"> | |
| 112 | + | <?= acp_csrf_field() ?> | |
| 113 | + | <input type="hidden" name="itmed_action" value="save"> | |
| 114 | + | ||
| 115 | + | <div class="acp-row"> | |
| 116 | + | <div class="acp-field"> | |
| 117 | + | <label class="acp-label" for="itmed_id"><?= t_default('acp.itmed.field_id', 'Item ID') ?></label> | |
| 118 | + | <input class="acp-input" type="number" min="1" id="itmed_id" name="id" value="<?= $editing ? (int)$editId : '' ?>" <?= $editing ? 'readonly' : '' ?> required> | |
| 119 | + | </div> | |
| 120 | + | <div class="acp-field"> | |
| 121 | + | <label class="acp-label" for="itmed_name"><?= t_default('acp.itmed.field_name', 'Name') ?></label> | |
| 122 | + | <input class="acp-input" type="text" id="itmed_name" name="name" value="<?= h((string)($editing['name'] ?? '')) ?>" required> | |
| 123 | + | </div> | |
| 124 | + | </div> | |
| 125 | + | ||
| 126 | + | <div class="acp-field"> | |
| 127 | + | <label class="acp-label"><?= t_default('acp.itmed.field_attrs', 'Attributes') ?></label> | |
| 128 | + | <table class="acp-table acp-table--editable" data-acp-table="itmedAttrs"> | |
| 129 | + | <thead> | |
| 130 | + | <tr> | |
| 131 | + | <?php foreach ($attrColumns as $colDef): ?> | |
| 132 | + | <th><?= h($colDef['label']) ?></th> | |
| 133 | + | <?php endforeach; ?> | |
| 134 | + | <th></th> | |
| 135 | + | </tr> | |
| 136 | + | </thead> | |
| 137 | + | <tbody data-acp-table-body> | |
| 138 | + | <?php foreach ($attrRows as $i => $row): ?> | |
| 139 | + | <tr> | |
| 140 | + | <?php foreach ($attrColumns as $colKey => $colDef): ?> | |
| 141 | + | <td><?= acp_table_cell_input($colDef, 'attr[' . (int)$i . '][' . $colKey . ']', (string)($row[$colKey] ?? '')) ?></td> | |
| 142 | + | <?php endforeach; ?> | |
| 143 | + | <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>×</button></td> | |
| 144 | + | </tr> | |
| 145 | + | <?php endforeach; ?> | |
| 146 | + | </tbody> | |
| 147 | + | </table> | |
| 148 | + | <button type="button" class="acp-btn acp-btn--sm" data-acp-table-add="itmedAttrs"> | |
| 149 | + | <i class="fa fa-plus"></i> <?= t_default('acp.itmed.add_attr', 'Add attribute') ?> | |
| 150 | + | </button> | |
| 151 | + | <template data-acp-table-template="itmedAttrs"> | |
| 152 | + | <tr> | |
| 153 | + | <?php foreach ($attrColumns as $colKey => $colDef): ?> | |
| 154 | + | <td><?= acp_table_cell_input($colDef, 'attr[__ROWIDX__][' . $colKey . ']', '') ?></td> | |
| 155 | + | <?php endforeach; ?> | |
| 156 | + | <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>×</button></td> | |
| 157 | + | </tr> | |
| 158 | + | </template> | |
| 159 | + | </div> | |
| 160 | + | ||
| 161 | + | <div class="acp-actions"> | |
| 162 | + | <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= $editing ? t_default('acp.itmed.save_btn', 'Save') : t_default('acp.itmed.add_btn', 'Add item') ?></button> | |
| 163 | + | <?php if ($editing): ?> | |
| 164 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('items_editor')) ?>"><?= t_default('acp.itmed.cancel', 'Cancel edit') ?></a> | |
| 165 | + | <?php endif; ?> | |
| 166 | + | </div> | |
| 167 | + | </form> | |
| 168 | + | </div> | |
| 169 | + | </section> | |
| 170 | + | ||
| 171 | + | <section class="acp-card"> | |
| 172 | + | <header class="acp-card-head"> | |
| 173 | + | <h2><?= t_default('acp.itmed.list_title', 'Published items') ?></h2> | |
| 174 | + | </header> | |
| 175 | + | <div class="acp-card-body is-flush"> | |
| 176 | + | <?php if ($list): ?> | |
| 177 | + | <?php if (count($list) > 8): ?> | |
| 178 | + | <div class="acp-toolbar"> | |
| 179 | + | <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;"> | |
| 180 | + | <input class="acp-input" type="search" data-acp-search-input="itmedTable" | |
| 181 | + | placeholder="<?= h(t_default('acp.itmed.search_placeholder', 'Search items...')) ?>"> | |
| 182 | + | </div> | |
| 183 | + | <span class="is-muted" data-acp-search-count="itmedTable"></span> | |
| 184 | + | </div> | |
| 185 | + | <?php endif; ?> | |
| 186 | + | <div class="acp-table-wrap"> | |
| 187 | + | <table class="acp-table" data-sortable id="itmedTable"> | |
| 188 | + | <thead> | |
| 189 | + | <tr> | |
| 190 | + | <th>ID</th> | |
| 191 | + | <th><?= t_default('acp.itmed.col_name', 'Name') ?></th> | |
| 192 | + | <th class="is-num"><?= t_default('acp.itmed.col_actions', 'Actions') ?></th> | |
| 193 | + | </tr> | |
| 194 | + | </thead> | |
| 195 | + | <tbody> | |
| 196 | + | <?php foreach ($list as $id => $rec): ?> | |
| 197 | + | <tr data-acp-search="<?= h(strtolower($id . ' ' . (string)($rec['name'] ?? ''))) ?>"> | |
| 198 | + | <td class="is-muted"><?= h((string)$id) ?></td> | |
| 199 | + | <td><?= h((string)($rec['name'] ?? '')) ?></td> | |
| 200 | + | <td class="is-nowrap is-num"> | |
| 201 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('items_editor', array('edit' => $id))) ?>"><i class="fa fa-pencil"></i></a> | |
| 202 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.itmed.confirm_remove', 'Remove this item?')) ?>"> | |
| 203 | + | <?= acp_csrf_field() ?> | |
| 204 | + | <input type="hidden" name="itmed_action" value="delete"> | |
| 205 | + | <input type="hidden" name="id" value="<?= h((string)$id) ?>"> | |
| 206 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-times"></i></button> | |
| 207 | + | </form> | |
| 208 | + | </td> | |
| 209 | + | </tr> | |
| 210 | + | <?php endforeach; ?> | |
| 211 | + | </tbody> | |
| 212 | + | </table> | |
| 213 | + | </div> | |
| 214 | + | <?php else: ?> | |
| 215 | + | <?php acp_empty(t_default('acp.itmed.empty', 'No items published yet.'), 'fa-shield'); ?> | |
| 216 | + | <?php endif; ?> | |
| 217 | + | </div> | |
| 218 | + | </section> | |
| 219 | + | ||
| 220 | + | <p class="acp-hint"><a href="<?= h(acp_url('serverinfo')) ?>"><i class="fa fa-arrow-left"></i> <?= t_default('acp.itmed.back', 'Back to Server Info') ?></a></p> |
| @@ -0,0 +1,173 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Landing Page | |
| 4 | + | * Icon: fa-flag | |
| 5 | + | * Group: Content | |
| 6 | + | * Order: 8 | |
| 7 | + | * Description: Show a standalone page from landing/ instead of the front page. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 16 | + | ||
| 17 | + | $enabled = empty($_POST['enabled']) ? '' : '1'; | |
| 18 | + | $file = trim((string)($_POST['file'] ?? '')); | |
| 19 | + | ||
| 20 | + | if ($file !== '' && !landing_is_valid_file($file)) { | |
| 21 | + | acp_flash_error(t('acp.land.bad_file')); | |
| 22 | + | acp_redirect('landing'); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | if ($enabled === '1') { | |
| 26 | + | if (!is_dir(landing_root())) { | |
| 27 | + | acp_flash_error(t('acp.land.no_folder', ['folder' => '<code>landing/</code>'])); | |
| 28 | + | acp_redirect('landing'); | |
| 29 | + | } | |
| 30 | + | if ($file === '' || !is_file(landing_root() . '/' . $file)) { | |
| 31 | + | acp_flash_error(t('acp.land.pick_page', ['folder' => '<code>landing/</code>'])); | |
| 32 | + | acp_redirect('landing'); | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | setting_set('landing.enabled', $enabled); | |
| 37 | + | setting_set('landing.file', $file); | |
| 38 | + | ||
| 39 | + | acp_log('landing.toggle', $file, ['enabled' => $enabled === '1']); | |
| 40 | + | ||
| 41 | + | acp_flash_success($enabled === '1' | |
| 42 | + | ? t('acp.land.live', ['path' => '<code>landing/' . h($file) . '</code>']) | |
| 43 | + | : t('acp.land.off')); | |
| 44 | + | ||
| 45 | + | acp_redirect('landing'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | $files = landing_available_files(); | |
| 49 | + | $hasFolder = is_dir(landing_root()); | |
| 50 | + | $enabled = landing_enabled(); | |
| 51 | + | $current = landing_file(); | |
| 52 | + | $live = landing_ready(); | |
| 53 | + | ?> | |
| 54 | + | ||
| 55 | + | <div class="acp-toolbar"> | |
| 56 | + | <div> | |
| 57 | + | <?php if ($live): ?> | |
| 58 | + | <span class="acp-pill acp-pill--green"><?= t('acp.land.live_pill') ?></span> | |
| 59 | + | <?php elseif ($enabled): ?> | |
| 60 | + | <span class="acp-pill acp-pill--red"><?= t('acp.land.missing_pill') ?></span> | |
| 61 | + | <?php else: ?> | |
| 62 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.land.off_pill') ?></span> | |
| 63 | + | <?php endif; ?> | |
| 64 | + | </div> | |
| 65 | + | <div class="acp-actions is-tight"> | |
| 66 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site()) ?>" target="_blank" rel="noopener"> | |
| 67 | + | <i class="fa fa-external-link"></i> <?= t('acp.land.open_site') ?> | |
| 68 | + | </a> | |
| 69 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('index.php?site=1')) ?>" target="_blank" rel="noopener"> | |
| 70 | + | <i class="fa fa-sign-in"></i> <?= t('acp.land.skip') ?> | |
| 71 | + | </a> | |
| 72 | + | </div> | |
| 73 | + | </div> | |
| 74 | + | ||
| 75 | + | <div class="acp-grid acp-grid--2"> | |
| 76 | + | ||
| 77 | + | <section class="acp-card"> | |
| 78 | + | <header class="acp-card-head"> | |
| 79 | + | <h2><?= t('acp.land.title') ?></h2> | |
| 80 | + | <p><?= t('acp.land.sub') ?></p> | |
| 81 | + | </header> | |
| 82 | + | <div class="acp-card-body"> | |
| 83 | + | ||
| 84 | + | <?php if (!$hasFolder): ?> | |
| 85 | + | <div class="acp-flash acp-flash--error"> | |
| 86 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 87 | + | <span><?= t('acp.land.no_folder_flash', [ | |
| 88 | + | 'folder' => '<code>landing/</code>', | |
| 89 | + | 'index' => '<code>index.html</code>', | |
| 90 | + | 'indexphp' => '<code>index.php</code>', | |
| 91 | + | ]) ?></span> | |
| 92 | + | </div> | |
| 93 | + | <?php elseif (!$files): ?> | |
| 94 | + | <div class="acp-flash acp-flash--error"> | |
| 95 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 96 | + | <span><?= t('acp.land.no_pages_flash', [ | |
| 97 | + | 'folder' => '<code>landing/</code>', | |
| 98 | + | 'html' => '<code>.html</code>', | |
| 99 | + | 'php' => '<code>.php</code>', | |
| 100 | + | ]) ?></span> | |
| 101 | + | </div> | |
| 102 | + | <?php endif; ?> | |
| 103 | + | ||
| 104 | + | <form method="post"> | |
| 105 | + | <?= acp_csrf_field() ?> | |
| 106 | + | ||
| 107 | + | <div class="acp-field"> | |
| 108 | + | <label class="acp-label" for="file"><?= t('acp.land.page_to_show') ?></label> | |
| 109 | + | <select class="acp-select" id="file" name="file" <?= $files ? '' : 'disabled' ?>> | |
| 110 | + | <?php if (!$files): ?> | |
| 111 | + | <option value=""><?= t('acp.land.nothing_yet') ?></option> | |
| 112 | + | <?php endif; ?> | |
| 113 | + | <?php foreach ($files as $name): ?> | |
| 114 | + | <option value="<?= h($name) ?>" <?= $name === $current ? 'selected' : '' ?>> | |
| 115 | + | <?= h('landing/' . $name) ?> | |
| 116 | + | </option> | |
| 117 | + | <?php endforeach; ?> | |
| 118 | + | </select> | |
| 119 | + | </div> | |
| 120 | + | ||
| 121 | + | <div class="acp-field"> | |
| 122 | + | <label style="display:flex;align-items:center;gap:8px;font-weight:400;"> | |
| 123 | + | <input type="checkbox" name="enabled" value="1" <?= $enabled ? 'checked' : '' ?>> | |
| 124 | + | <span><?= t('acp.land.show_at_root') ?></span> | |
| 125 | + | </label> | |
| 126 | + | </div> | |
| 127 | + | ||
| 128 | + | <div class="acp-actions"> | |
| 129 | + | <button class="acp-btn acp-btn--green" type="submit"> | |
| 130 | + | <i class="fa fa-check"></i> <?= t('acp.land.save') ?> | |
| 131 | + | </button> | |
| 132 | + | </div> | |
| 133 | + | </form> | |
| 134 | + | </div> | |
| 135 | + | </section> | |
| 136 | + | ||
| 137 | + | <section class="acp-card"> | |
| 138 | + | <header class="acp-card-head"> | |
| 139 | + | <h2><?= t('acp.land.behaves_title') ?></h2> | |
| 140 | + | <p><?= t('acp.land.behaves_sub') ?></p> | |
| 141 | + | </header> | |
| 142 | + | <div class="acp-card-body"> | |
| 143 | + | <table class="acp-table"> | |
| 144 | + | <tbody> | |
| 145 | + | <tr> | |
| 146 | + | <td><?= t('acp.land.col_folder') ?></td> | |
| 147 | + | <td><code><?= h(ZNOTE_LANDING_DIR) ?>/</code> <?= $hasFolder ? '' : '<em>' . t('acp.land.missing_suffix') . '</em>' ?></td> | |
| 148 | + | </tr> | |
| 149 | + | <tr> | |
| 150 | + | <td><?= t('acp.land.col_pages') ?></td> | |
| 151 | + | <td><?= $files ? h(implode(', ', $files)) : '—' ?></td> | |
| 152 | + | </tr> | |
| 153 | + | <tr> | |
| 154 | + | <td><?= t('acp.land.col_way_in') ?></td> | |
| 155 | + | <td><code>index.php?site=1</code></td> | |
| 156 | + | </tr> | |
| 157 | + | </tbody> | |
| 158 | + | </table> | |
| 159 | + | ||
| 160 | + | <p class="acp-hint"> | |
| 161 | + | <?= t('acp.land.hint1') ?> | |
| 162 | + | </p> | |
| 163 | + | <p class="acp-hint"> | |
| 164 | + | <?= t('acp.land.hint2', [ | |
| 165 | + | 'base' => '<code><base></code>', | |
| 166 | + | 'folder' => '<code>' . h(ZNOTE_LANDING_DIR) . '/</code>', | |
| 167 | + | 'link' => '<code><a href="/index.php?site=1">Enter</a></code>', | |
| 168 | + | ]) ?> | |
| 169 | + | </p> | |
| 170 | + | </div> | |
| 171 | + | </section> | |
| 172 | + | ||
| 173 | + | </div> |
| @@ -0,0 +1,518 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Layout | |
| 4 | + | * Icon: fa-paint-brush | |
| 5 | + | * Group: Settings | |
| 6 | + | * Order: 10 | |
| 7 | + | * Description: Pick the theme the public site is dressed in. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['clear_layout_repository_cache'])) { | |
| 16 | + | if (theme_repository_clear_cache()) { | |
| 17 | + | acp_log('layouts.cache_clear'); | |
| 18 | + | acp_flash_success(t('acp.laybr.cache_deleted')); | |
| 19 | + | } else { | |
| 20 | + | acp_flash_error(t('acp.laybr.cache_delete_failed', [ | |
| 21 | + | 'path' => '<code>' . h(theme_repository_cache_path()) . '</code>', | |
| 22 | + | ])); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | acp_redirect('layouts', array('tab' => 'browse', 'refresh' => 1)); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // --------------------------------------------------------------------------- | |
| 29 | + | // Activate a theme | |
| 30 | + | // --------------------------------------------------------------------------- | |
| 31 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install'])) { | |
| 32 | + | ||
| 33 | + | $key = theme_sanitize((string)$_POST['install']); | |
| 34 | + | $overwrite = !empty($_POST['overwrite']); | |
| 35 | + | $result = theme_repository_install($key, $overwrite); | |
| 36 | + | ||
| 37 | + | if ($result === '') { | |
| 38 | + | acp_log('layouts.install', $key); | |
| 39 | + | acp_flash_success(t('acp.lay.installed', [ | |
| 40 | + | 'theme' => '<strong>' . h($key) . '</strong>', | |
| 41 | + | 'path' => '<code>layouts/' . h($key) . '/</code>', | |
| 42 | + | ])); | |
| 43 | + | } elseif ($result === 'already-installed') { | |
| 44 | + | acp_flash_error(t('acp.lay.already_installed', ['theme' => '<strong>' . h($key) . '</strong>'])); | |
| 45 | + | } else { | |
| 46 | + | acp_flash_error(t('acp.lay.install_failed', ['error' => h($result)])); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | acp_redirect('layouts', array('tab' => 'browse')); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['uninstall'])) { | |
| 53 | + | ||
| 54 | + | $key = theme_sanitize((string)$_POST['uninstall']); | |
| 55 | + | $result = theme_uninstall($key); | |
| 56 | + | ||
| 57 | + | if ($result === '') { | |
| 58 | + | acp_log('layouts.uninstall', $key); | |
| 59 | + | acp_flash_success(t('acp.lay.removed', [ | |
| 60 | + | 'theme' => '<strong>' . h($key) . '</strong>', | |
| 61 | + | 'path' => '<code>layouts/</code>', | |
| 62 | + | ])); | |
| 63 | + | } else { | |
| 64 | + | acp_flash_error(t('acp.lay.remove_failed', ['error' => h($result)])); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | acp_redirect('layouts'); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['theme_options'])) { | |
| 71 | + | ||
| 72 | + | $target = theme_sanitize((string)$_POST['theme_options']); | |
| 73 | + | $themes = theme_list(); | |
| 74 | + | $options = isset($themes[$target]) ? theme_options($target) : array(); | |
| 75 | + | ||
| 76 | + | if ($options === array()) { | |
| 77 | + | acp_flash_error(t('acp.lay.no_options')); | |
| 78 | + | acp_redirect('layouts'); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | $saved = 0; | |
| 82 | + | $failed = 0; | |
| 83 | + | $savedKeys = array(); | |
| 84 | + | $uploads = 0; | |
| 85 | + | $errors = array(); | |
| 86 | + | ||
| 87 | + | foreach ($options as $key => $option) { | |
| 88 | + | ||
| 89 | + | if ($option['type'] === 'checkbox') { | |
| 90 | + | $value = empty($_POST['opt'][$key]) ? '' : '1'; | |
| 91 | + | } else { | |
| 92 | + | $value = trim((string)($_POST['opt'][$key] ?? '')); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // An uploaded image wins over whatever the path field says: the admin | |
| 96 | + | // picked a file, so that is the intent. | |
| 97 | + | if ($option['type'] === 'image') { | |
| 98 | + | $file = $_FILES['optfile']['tmp_name'][$key] ?? ''; | |
| 99 | + | $code = $_FILES['optfile']['error'][$key] ?? UPLOAD_ERR_NO_FILE; | |
| 100 | + | ||
| 101 | + | if ($code === UPLOAD_ERR_OK && is_uploaded_file((string)$file)) { | |
| 102 | + | $error = null; | |
| 103 | + | $stored = theme_image_store($target, $key, (string)$file, $error); | |
| 104 | + | ||
| 105 | + | if ($stored !== '') { | |
| 106 | + | $value = $stored; | |
| 107 | + | $uploads++; | |
| 108 | + | } else { | |
| 109 | + | $errors[] = $option['label'] . ': ' . (string)$error; | |
| 110 | + | continue; | |
| 111 | + | } | |
| 112 | + | } elseif ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE) { | |
| 113 | + | $errors[] = $option['label'] . ': ' . t('acp.lay.file_too_large'); | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | if (setting_set(theme_option_key($target, $key), $value)) { | |
| 119 | + | $saved++; | |
| 120 | + | $savedKeys[] = $key; | |
| 121 | + | } else $failed++; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | foreach ($errors as $message) { | |
| 125 | + | acp_flash_error($message); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if ($uploads > 0) { | |
| 129 | + | acp_flash_info(t('acp.lay.images_uploaded', [ | |
| 130 | + | 'n' => $uploads, | |
| 131 | + | 'path' => '<code>engine/img/theme/' . h($target) . '/</code>', | |
| 132 | + | ])); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | if ($saved > 0) { | |
| 136 | + | acp_log('layouts.options_save', $target, ['fields' => $savedKeys, 'uploads' => $uploads, 'failed' => $failed]); | |
| 137 | + | } | |
| 138 | + | if ($failed > 0) { | |
| 139 | + | acp_flash_error(t('acp.lay.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>'])); | |
| 140 | + | } else { | |
| 141 | + | acp_flash_success(t('acp.lay.options_saved', ['theme' => '<strong>' . h($themes[$target]['name']) . '</strong>'])); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | acp_redirect('layouts', array('options' => $target)); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 148 | + | $requested = theme_sanitize((string)($_POST['layout'] ?? '')); | |
| 149 | + | $themes = theme_list(); | |
| 150 | + | ||
| 151 | + | if ($requested === '' || !isset($themes[$requested])) { | |
| 152 | + | acp_flash_error(t('acp.lay.unknown_theme')); | |
| 153 | + | } elseif (!$themes[$requested]['compatible']) { | |
| 154 | + | acp_flash_error(h(implode(' ', $themes[$requested]['compatibility_errors']))); | |
| 155 | + | } elseif (theme_file_exists_in($requested, 'shells/default.php') === false) { | |
| 156 | + | acp_flash_error(t('acp.lay.no_shell', [ | |
| 157 | + | 'theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>', | |
| 158 | + | 'file' => '<code>shells/default.php</code>', | |
| 159 | + | ])); | |
| 160 | + | } elseif (setting_set('layout', $requested)) { | |
| 161 | + | acp_log('layouts.activate', $requested); | |
| 162 | + | acp_flash_success(t('acp.lay.switched', ['theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>'])); | |
| 163 | + | } else { | |
| 164 | + | acp_flash_error(t('acp.lay.save_setting_failed', ['table' => '<code>znote_config</code>'])); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | acp_redirect('layouts'); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** A theme is only usable if it can produce a page frame. */ | |
| 171 | + | function theme_file_exists_in(string $theme, string $relative): bool { | |
| 172 | + | return is_file(theme_root() . '/' . $theme . '/' . ltrim($relative, '/')); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /** | |
| 176 | + | * How many shells / views / pages a theme ships, for the card summary. | |
| 177 | + | * | |
| 178 | + | * readdir rather than glob: measured on Windows with 100 themes installed, | |
| 179 | + | * glob takes 159ms and readdir 37ms for the same 309 directory reads. glob | |
| 180 | + | * builds and sorts a match array we immediately throw away. | |
| 181 | + | */ | |
| 182 | + | function acp_theme_counts(string $theme): array { | |
| 183 | + | static $cache = []; | |
| 184 | + | if (isset($cache[$theme])) { | |
| 185 | + | return $cache[$theme]; | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | $counts = ['shells' => 0, 'views' => 0, 'pages' => 0]; | |
| 189 | + | $base = theme_root() . '/' . $theme; | |
| 190 | + | ||
| 191 | + | foreach (array_keys($counts) as $sub) { | |
| 192 | + | $handle = @opendir($base . '/' . $sub); | |
| 193 | + | if ($handle === false) { | |
| 194 | + | continue; | |
| 195 | + | } | |
| 196 | + | while (($entry = readdir($handle)) !== false) { | |
| 197 | + | if (substr($entry, -4) === '.php') { | |
| 198 | + | $counts[$sub]++; | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | closedir($handle); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | return $cache[$theme] = $counts; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | $themes = theme_list(); | |
| 208 | + | $active = theme_active(); | |
| 209 | + | ||
| 210 | + | // The Browse tab is a separate view over the same module. | |
| 211 | + | if (($_GET['tab'] ?? '') === 'browse') { | |
| 212 | + | // "Refresh catalogue" also wipes the local caches - a stale cache is what | |
| 213 | + | // usually keeps a just-installed or updated theme (and its options/locale) | |
| 214 | + | // from showing correctly. | |
| 215 | + | if (isset($_GET['refresh']) && function_exists('znote_cache_flush')) { | |
| 216 | + | znote_cache_flush(); | |
| 217 | + | acp_log('layouts.catalogue_refresh'); | |
| 218 | + | } | |
| 219 | + | include ACP_ROOT . '/modules/_partials/layouts_browse.php'; | |
| 220 | + | return; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Options get a page of their own. Resolved against the full theme list, before | |
| 224 | + | // the search and paging below narrow it - a theme on page 3 has options too. | |
| 225 | + | $optionTheme = theme_sanitize((string)($_GET['options'] ?? '')); | |
| 226 | + | if ($optionTheme !== '' && isset($themes[$optionTheme])) { | |
| 227 | + | $optionList = theme_options($optionTheme); | |
| 228 | + | ||
| 229 | + | if ($optionList) { | |
| 230 | + | include ACP_ROOT . '/modules/_partials/layouts_options.php'; | |
| 231 | + | return; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | acp_flash_error('That theme has no options to set.'); | |
| 235 | + | acp_redirect('layouts'); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // --------------------------------------------------------------------------- | |
| 239 | + | // Search and paging. | |
| 240 | + | // | |
| 241 | + | // Both server side on purpose: only the themes actually on screen get their | |
| 242 | + | // shells/views/pages counted, so the page costs the same with 5 themes | |
| 243 | + | // installed or 500. A client-side filter would have had to render every card. | |
| 244 | + | // --------------------------------------------------------------------------- | |
| 245 | + | const ACP_THEMES_PER_PAGE = 12; | |
| 246 | + | ||
| 247 | + | $search = trim((string)($_GET['q'] ?? '')); | |
| 248 | + | ||
| 249 | + | // The _-prefixed folders are references to copy, not themes to run. They get a | |
| 250 | + | // line in the "making a theme" card at the bottom instead of a card each up | |
| 251 | + | // here, where they only push real themes onto a second page. One that somehow | |
| 252 | + | // ended up active still shows, so it can be switched away from. | |
| 253 | + | $examples = array_filter($themes, static function (array $t) use ($active): bool { | |
| 254 | + | return !empty($t['is_example']) && $t['key'] !== $active; | |
| 255 | + | }); | |
| 256 | + | $themes = array_diff_key($themes, $examples); | |
| 257 | + | ||
| 258 | + | $total = count($themes); | |
| 259 | + | ||
| 260 | + | if ($search !== '') { | |
| 261 | + | $needle = strtolower($search); | |
| 262 | + | $themes = array_filter($themes, static function (array $t) use ($needle): bool { | |
| 263 | + | return str_contains(strtolower($t['name'] . ' ' . $t['key'] . ' ' . $t['author'] . ' ' . $t['description']), $needle); | |
| 264 | + | }); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | $matched = count($themes); | |
| 268 | + | $pageCount = max(1, (int)ceil($matched / ACP_THEMES_PER_PAGE)); | |
| 269 | + | $page = max(1, min($pageCount, intv($_GET['tp'] ?? 1))); | |
| 270 | + | $themes = array_slice($themes, ($page - 1) * ACP_THEMES_PER_PAGE, ACP_THEMES_PER_PAGE, true); | |
| 271 | + | ||
| 272 | + | /** Keep the current search when building a paging link. */ | |
| 273 | + | function acp_theme_page_url(int $page, string $search): string { | |
| 274 | + | $params = ['tp' => $page]; | |
| 275 | + | if ($search !== '') { | |
| 276 | + | $params['q'] = $search; | |
| 277 | + | } | |
| 278 | + | return acp_url('layouts', $params); | |
| 279 | + | } | |
| 280 | + | $hasTable = znote_table_exists('znote_config'); | |
| 281 | + | ?> | |
| 282 | + | ||
| 283 | + | <?php if (!$hasTable): ?> | |
| 284 | + | <div class="acp-flash acp-flash--error"> | |
| 285 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 286 | + | <span> | |
| 287 | + | <?= t('acp.lay.table_missing_banner', [ | |
| 288 | + | 'table' => '<code>znote_config</code>', | |
| 289 | + | 'default' => '<strong>default</strong>', | |
| 290 | + | 'file' => '<code>SQL/migrations/2.0.0_znote_config.sql</code>', | |
| 291 | + | ]) ?> | |
| 292 | + | </span> | |
| 293 | + | </div> | |
| 294 | + | <?php endif; ?> | |
| 295 | + | ||
| 296 | + | <div class="acp-toolbar"> | |
| 297 | + | <div></div> | |
| 298 | + | <div class="acp-actions is-tight"> | |
| 299 | + | <?php if (theme_repository_config()['enabled']): ?> | |
| 300 | + | <a class="acp-btn" href="<?= h(acp_url('layouts', array('tab' => 'browse'))) ?>"> | |
| 301 | + | <i class="fa fa-cloud-download"></i> <?= t('acp.lay.browse_themes') ?> | |
| 302 | + | </a> | |
| 303 | + | <?php endif; ?> | |
| 304 | + | </div> | |
| 305 | + | </div> | |
| 306 | + | ||
| 307 | + | <div class="acp-stats"> | |
| 308 | + | <?php | |
| 309 | + | acp_stat(t('acp.lay.stat_installed'), count($themes), 'fa-paint-brush', null, 'purple'); | |
| 310 | + | acp_stat(t('acp.lay.stat_active'), $themes[$active]['name'] ?? $active, 'fa-check-circle', null, 'green'); | |
| 311 | + | ?> | |
| 312 | + | </div> | |
| 313 | + | ||
| 314 | + | <?php if ($total > ACP_THEMES_PER_PAGE || $search !== ''): ?> | |
| 315 | + | <div class="acp-toolbar"> | |
| 316 | + | <form method="get" style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;"> | |
| 317 | + | <input type="hidden" name="p" value="layouts"> | |
| 318 | + | <input class="acp-input" type="search" name="q" value="<?= h($search) ?>" | |
| 319 | + | placeholder="<?= h(t('acp.lay.search_placeholder')) ?>"> | |
| 320 | + | <button class="acp-btn" type="submit"><i class="fa fa-search"></i></button> | |
| 321 | + | <?php if ($search !== ''): ?> | |
| 322 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.clear') ?></a> | |
| 323 | + | <?php endif; ?> | |
| 324 | + | </form> | |
| 325 | + | <span class="is-muted"> | |
| 326 | + | <?php if ($search !== ''): ?> | |
| 327 | + | <?= t('acp.lay.matched_of_total', ['matched' => (int)$matched, 'total' => (int)$total]) ?> | |
| 328 | + | <?php else: ?> | |
| 329 | + | <?= t('acp.lay.total_themes', ['total' => (int)$total]) ?> | |
| 330 | + | <?php endif; ?> | |
| 331 | + | <?php if ($pageCount > 1): ?> | |
| 332 | + | · <?= t('acp.lay.page_of', ['page' => (int)$page, 'pageCount' => (int)$pageCount]) ?> | |
| 333 | + | <?php endif; ?> | |
| 334 | + | </span> | |
| 335 | + | </div> | |
| 336 | + | <?php endif; ?> | |
| 337 | + | ||
| 338 | + | <div class="acp-media"> | |
| 339 | + | <?php foreach ($themes as $key => $theme): | |
| 340 | + | $isActive = ($key === $active); | |
| 341 | + | $isUsable = theme_file_exists_in($key, 'shells/default.php') && $theme['compatible']; | |
| 342 | + | $counts = acp_theme_counts($key); | |
| 343 | + | $views = $counts['views']; | |
| 344 | + | $pages = $counts['pages']; | |
| 345 | + | $shells = $counts['shells']; | |
| 346 | + | ?> | |
| 347 | + | <article class="acp-media-item"<?= $isActive ? ' style="border-color:var(--acp-green);"' : '' ?>> | |
| 348 | + | <?php if (!empty($theme['screenshot'])): ?> | |
| 349 | + | <img src="<?= h(acp_site($theme['screenshot'])) ?>" alt="<?= h($theme['name']) ?>" loading="lazy"> | |
| 350 | + | <?php else: ?> | |
| 351 | + | <div style="display:grid;place-items:center;height:170px;background:var(--acp-panel-2);color:var(--acp-fg-muted);"> | |
| 352 | + | <span><i class="fa fa-picture-o"></i> no screenshot.png</span> | |
| 353 | + | </div> | |
| 354 | + | <?php endif; ?> | |
| 355 | + | ||
| 356 | + | <div class="acp-media-body"> | |
| 357 | + | <h3> | |
| 358 | + | <?= h($theme['name']) ?> | |
| 359 | + | <?php if ($isActive): ?> | |
| 360 | + | <span class="acp-pill acp-pill--green"><?= t('acp.lay.active_pill') ?></span> | |
| 361 | + | <?php endif; ?> | |
| 362 | + | <?php if (!empty($theme['is_example'])): ?> | |
| 363 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.lay.template_pill') ?></span> | |
| 364 | + | <?php endif; ?> | |
| 365 | + | <?php if (!$theme['compatible']): ?> | |
| 366 | + | <span class="acp-pill acp-pill--red"><?= h(t_default('acp.lay.incompatible', 'Incompatible')) ?></span> | |
| 367 | + | <?php endif; ?> | |
| 368 | + | </h3> | |
| 369 | + | ||
| 370 | + | <p> | |
| 371 | + | <?= h($theme['description']) ?> | |
| 372 | + | </p> | |
| 373 | + | ||
| 374 | + | <p class="is-muted" style="font-size:12px;"> | |
| 375 | + | <code>layouts/<?= h($key) ?>/</code> | |
| 376 | + | <?php if ($theme['author'] !== ''): ?> | |
| 377 | + | · <?= t('acp.lay.by_author', ['author' => h($theme['author'])]) ?> | |
| 378 | + | <?php endif; ?> | |
| 379 | + | <?php if ($theme['version'] !== ''): ?> | |
| 380 | + | · v<?= h($theme['version']) ?> | |
| 381 | + | <?php endif; ?> | |
| 382 | + | </p> | |
| 383 | + | ||
| 384 | + | <p style="font-size:12px;"> | |
| 385 | + | <span class="acp-pill acp-pill--blue"><?= t('acp.lay.shell_count', ['n' => $shells]) ?></span> | |
| 386 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.lay.view_count', ['n' => $views]) ?></span> | |
| 387 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.lay.page_count', ['n' => $pages]) ?></span> | |
| 388 | + | </p> | |
| 389 | + | ||
| 390 | + | <?php if (!$isUsable): ?> | |
| 391 | + | <p class="is-muted" style="font-size:12px;color:var(--acp-red);"> | |
| 392 | + | <?= $theme['compatible'] | |
| 393 | + | ? t('acp.lay.not_usable', ['file' => '<code>shells/default.php</code>']) | |
| 394 | + | : h(implode(' ', $theme['compatibility_errors'])) ?> | |
| 395 | + | </p> | |
| 396 | + | <?php elseif ($views === 0): ?> | |
| 397 | + | <p class="is-muted" style="font-size:12px;"> | |
| 398 | + | <?= t('acp.lay.no_views') ?> | |
| 399 | + | </p> | |
| 400 | + | <?php endif; ?> | |
| 401 | + | </div> | |
| 402 | + | ||
| 403 | + | <div class="acp-media-foot"> | |
| 404 | + | <?php if ($isActive): ?> | |
| 405 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('index.php')) ?>" target="_blank" rel="noopener"> | |
| 406 | + | <i class="fa fa-external-link"></i> <?= t('acp.lay.view_site') ?> | |
| 407 | + | </a> | |
| 408 | + | <?php else: ?> | |
| 409 | + | <form class="acp-inline-form" method="post" | |
| 410 | + | data-confirm="<?= h(t('acp.lay.confirm_switch')) ?>"> | |
| 411 | + | <?= acp_csrf_field() ?> | |
| 412 | + | <input type="hidden" name="layout" value="<?= h($key) ?>"> | |
| 413 | + | <button class="acp-btn acp-btn--sm" type="submit" <?= $isUsable ? '' : 'disabled' ?>> | |
| 414 | + | <i class="fa fa-check"></i> <?= t('acp.lay.activate') ?> | |
| 415 | + | </button> | |
| 416 | + | </form> | |
| 417 | + | <?php endif; ?> | |
| 418 | + | ||
| 419 | + | <?php if (theme_options($key)): ?> | |
| 420 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('layouts', array('options' => $key))) ?>"> | |
| 421 | + | <i class="fa fa-sliders"></i> <?= t('acp.lay.options') ?> | |
| 422 | + | </a> | |
| 423 | + | <?php endif; ?> | |
| 424 | + | ||
| 425 | + | <?php if (!$isActive && $key !== 'default' && empty($theme['is_example'])): ?> | |
| 426 | + | <form class="acp-inline-form" method="post" | |
| 427 | + | data-confirm="<?= h(t('acp.lay.confirm_delete', ['path' => 'layouts/' . $key . '/'])) ?>"> | |
| 428 | + | <?= acp_csrf_field() ?> | |
| 429 | + | <input type="hidden" name="uninstall" value="<?= h($key) ?>"> | |
| 430 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"> | |
| 431 | + | <i class="fa fa-trash"></i> <?= t('acp.lay.remove') ?> | |
| 432 | + | </button> | |
| 433 | + | </form> | |
| 434 | + | <?php endif; ?> | |
| 435 | + | </div> | |
| 436 | + | </article> | |
| 437 | + | <?php endforeach; ?> | |
| 438 | + | </div> | |
| 439 | + | ||
| 440 | + | <?php if ($matched === 0): ?> | |
| 441 | + | <section class="acp-card"> | |
| 442 | + | <div class="acp-card-body"> | |
| 443 | + | <?php acp_empty(t('acp.lay.no_match', ['search' => $search]), 'fa-search'); ?> | |
| 444 | + | <div class="acp-actions" style="justify-content:center;"> | |
| 445 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.show_all') ?></a> | |
| 446 | + | </div> | |
| 447 | + | </div> | |
| 448 | + | </section> | |
| 449 | + | <?php endif; ?> | |
| 450 | + | ||
| 451 | + | <?php if ($pageCount > 1): ?> | |
| 452 | + | <nav class="acp-actions" style="justify-content:center;margin:18px 0 24px;" aria-label="<?= h(t('acp.lay.pages_label')) ?>"> | |
| 453 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page <= 1 ? ' is-disabled' : '' ?>" | |
| 454 | + | href="<?= h(acp_theme_page_url(max(1, $page - 1), $search)) ?>" | |
| 455 | + | <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>> | |
| 456 | + | <i class="fa fa-angle-left"></i> <?= t('acp.lay.previous') ?> | |
| 457 | + | </a> | |
| 458 | + | ||
| 459 | + | <?php for ($i = 1; $i <= $pageCount; $i++): ?> | |
| 460 | + | <?php if ($i === $page): ?> | |
| 461 | + | <span class="acp-btn acp-btn--sm"><?= $i ?></span> | |
| 462 | + | <?php else: ?> | |
| 463 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_theme_page_url($i, $search)) ?>"><?= $i ?></a> | |
| 464 | + | <?php endif; ?> | |
| 465 | + | <?php endfor; ?> | |
| 466 | + | ||
| 467 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page >= $pageCount ? ' is-disabled' : '' ?>" | |
| 468 | + | href="<?= h(acp_theme_page_url(min($pageCount, $page + 1), $search)) ?>" | |
| 469 | + | <?= $page >= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>> | |
| 470 | + | <?= t('acp.lay.next') ?> <i class="fa fa-angle-right"></i> | |
| 471 | + | </a> | |
| 472 | + | </nav> | |
| 473 | + | <?php endif; ?> | |
| 474 | + | ||
| 475 | + | <section class="acp-card"> | |
| 476 | + | <header class="acp-card-head"> | |
| 477 | + | <h2>Making a theme</h2> | |
| 478 | + | <p>The short version</p> | |
| 479 | + | </header> | |
| 480 | + | <div class="acp-card-body"> | |
| 481 | + | <p> | |
| 482 | + | Copy <code>layouts/_example/</code>, rename the folder, edit | |
| 483 | + | <code>theme.json</code>. Your theme is listed here immediately. | |
| 484 | + | </p> | |
| 485 | + | <p> | |
| 486 | + | The only file a theme truly needs is <code>shells/default.php</code> — the frame | |
| 487 | + | your pages render inside. Anything else you leave out is taken from the default theme, | |
| 488 | + | so a stylesheet and a shell are enough to redress the entire site. | |
| 489 | + | </p> | |
| 490 | + | <p> | |
| 491 | + | The picture on these cards is a file named <code>screenshot.png</code> at the root of | |
| 492 | + | the theme folder — nothing to declare, drop it in and reload this page. | |
| 493 | + | Any size works; roughly 3:2 shows best. | |
| 494 | + | </p> | |
| 495 | + | <?php if ($examples): ?> | |
| 496 | + | <p> | |
| 497 | + | Two folders in <code>layouts/</code> are starting points rather than themes, so they | |
| 498 | + | are kept out of the grid above: | |
| 499 | + | </p> | |
| 500 | + | <ul> | |
| 501 | + | <?php foreach ($examples as $exampleKey => $example): ?> | |
| 502 | + | <li> | |
| 503 | + | <code><?= h('layouts/' . $exampleKey . '/') ?></code> | |
| 504 | + | — <?= h($example['description'] !== '' ? $example['description'] : $example['name']) ?> | |
| 505 | + | </li> | |
| 506 | + | <?php endforeach; ?> | |
| 507 | + | </ul> | |
| 508 | + | <p> | |
| 509 | + | Copy one, rename the folder, and it appears above on the next page load. Never edit | |
| 510 | + | them in place: they are the reference every other theme is copied from. | |
| 511 | + | </p> | |
| 512 | + | <?php endif; ?> | |
| 513 | + | <p> | |
| 514 | + | Full instructions, including the list of CSS classes the pages emit, are in | |
| 515 | + | <code>layouts/README.md</code>. | |
| 516 | + | </p> | |
| 517 | + | </div> | |
| 518 | + | </section> |
| @@ -0,0 +1,133 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Login Attempts | |
| 4 | + | * Icon: fa-lock | |
| 5 | + | * Group: Operations | |
| 6 | + | * Order: 50 | |
| 7 | + | * Description: Recent login attempts and which IPs are currently locked out. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if (!znote_table_exists('znote_login_attempts')) { | |
| 16 | + | acp_flash_error(t_default('acp.lattempt.no_table', 'Run the pending database migration first (Admin Panel > Migrations).')); | |
| 17 | + | acp_redirect('migrations'); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['do'] ?? '') === 'clear_ip') { | |
| 21 | + | $ip = substr(trim((string)($_POST['ip'] ?? '')), 0, 45); | |
| 22 | + | if ($ip !== '') { | |
| 23 | + | db()->execute("DELETE FROM `znote_login_attempts` WHERE `ip` = ? AND `success` = 0;", [$ip]); | |
| 24 | + | acp_log('login_guard.clear_ip', $ip); | |
| 25 | + | acp_flash_success(t_default('acp.lattempt.cleared', 'Cleared - that IP can log in again immediately.')); | |
| 26 | + | } | |
| 27 | + | acp_redirect('login_attempts'); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | $cfg = znote_login_guard_config(); | |
| 31 | + | ||
| 32 | + | $lockedRows = db()->fetchAll(" | |
| 33 | + | SELECT `ip`, COUNT(*) AS `failures`, MAX(`created_at`) AS `last_failure` | |
| 34 | + | FROM `znote_login_attempts` | |
| 35 | + | WHERE `success` = 0 AND `created_at` >= ? | |
| 36 | + | GROUP BY `ip` | |
| 37 | + | HAVING `failures` >= ? | |
| 38 | + | ORDER BY `last_failure` DESC; | |
| 39 | + | ", [time() - $cfg['window_seconds'], $cfg['threshold']]); | |
| 40 | + | $lockedRows = is_array($lockedRows) ? $lockedRows : array(); | |
| 41 | + | ||
| 42 | + | $recent = db()->fetchAll(" | |
| 43 | + | SELECT `ip`, `username`, `success`, `created_at` | |
| 44 | + | FROM `znote_login_attempts` | |
| 45 | + | ORDER BY `id` DESC | |
| 46 | + | LIMIT 100; | |
| 47 | + | "); | |
| 48 | + | $recent = is_array($recent) ? $recent : array(); | |
| 49 | + | ?> | |
| 50 | + | ||
| 51 | + | <div class="acp-stats"> | |
| 52 | + | <?php | |
| 53 | + | acp_stat(t_default('acp.lattempt.stat_locked', 'IPs locked out now'), count($lockedRows), 'fa-lock', null, count($lockedRows) > 0 ? 'red' : 'green'); | |
| 54 | + | acp_stat(t_default('acp.lattempt.stat_guard', 'Protection'), $cfg['enabled'] ? t_default('acp.lattempt.on', 'On') : t_default('acp.lattempt.off', 'Off'), 'fa-shield', acp_url('settings'), $cfg['enabled'] ? 'green' : 'grey'); | |
| 55 | + | ?> | |
| 56 | + | </div> | |
| 57 | + | ||
| 58 | + | <?php if ($lockedRows): ?> | |
| 59 | + | <section class="acp-card"> | |
| 60 | + | <header class="acp-card-head"> | |
| 61 | + | <h2><?= t_default('acp.lattempt.locked_title', 'Currently locked out') ?></h2> | |
| 62 | + | </header> | |
| 63 | + | <div class="acp-card-body is-flush"> | |
| 64 | + | <div class="acp-table-wrap"> | |
| 65 | + | <table class="acp-table"> | |
| 66 | + | <thead> | |
| 67 | + | <tr> | |
| 68 | + | <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th> | |
| 69 | + | <th class="is-num"><?= t_default('acp.lattempt.col_failures', 'Failures') ?></th> | |
| 70 | + | <th><?= t_default('acp.lattempt.col_last', 'Last attempt') ?></th> | |
| 71 | + | <th class="is-num"></th> | |
| 72 | + | </tr> | |
| 73 | + | </thead> | |
| 74 | + | <tbody> | |
| 75 | + | <?php foreach ($lockedRows as $row): ?> | |
| 76 | + | <tr> | |
| 77 | + | <td><code><?= h((string)$row['ip']) ?></code></td> | |
| 78 | + | <td class="is-num"><?= (int)$row['failures'] ?></td> | |
| 79 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$row['last_failure'], true)) ?></td> | |
| 80 | + | <td class="is-num"> | |
| 81 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.lattempt.confirm_clear', 'Let this IP try again immediately?')) ?>"> | |
| 82 | + | <?= acp_csrf_field() ?> | |
| 83 | + | <input type="hidden" name="do" value="clear_ip"> | |
| 84 | + | <input type="hidden" name="ip" value="<?= h((string)$row['ip']) ?>"> | |
| 85 | + | <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit"><?= t_default('acp.lattempt.clear_btn', 'Unlock') ?></button> | |
| 86 | + | </form> | |
| 87 | + | </td> | |
| 88 | + | </tr> | |
| 89 | + | <?php endforeach; ?> | |
| 90 | + | </tbody> | |
| 91 | + | </table> | |
| 92 | + | </div> | |
| 93 | + | </div> | |
| 94 | + | </section> | |
| 95 | + | <?php endif; ?> | |
| 96 | + | ||
| 97 | + | <section class="acp-card"> | |
| 98 | + | <header class="acp-card-head"> | |
| 99 | + | <h2><?= t_default('acp.lattempt.recent_title', 'Last 100 attempts') ?></h2> | |
| 100 | + | </header> | |
| 101 | + | <div class="acp-card-body is-flush"> | |
| 102 | + | <?php if ($recent): ?> | |
| 103 | + | <div class="acp-table-wrap"> | |
| 104 | + | <table class="acp-table" data-sortable> | |
| 105 | + | <thead> | |
| 106 | + | <tr> | |
| 107 | + | <th><?= t_default('acp.lattempt.col_when', 'When') ?></th> | |
| 108 | + | <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th> | |
| 109 | + | <th><?= t_default('acp.lattempt.col_username', 'Username tried') ?></th> | |
| 110 | + | <th><?= t_default('acp.lattempt.col_result', 'Result') ?></th> | |
| 111 | + | </tr> | |
| 112 | + | </thead> | |
| 113 | + | <tbody> | |
| 114 | + | <?php foreach ($recent as $row): ?> | |
| 115 | + | <tr> | |
| 116 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created_at'], true)) ?></td> | |
| 117 | + | <td><code><?= h((string)$row['ip']) ?></code></td> | |
| 118 | + | <td><?= h((string)$row['username']) ?></td> | |
| 119 | + | <td> | |
| 120 | + | <span class="acp-pill acp-pill--<?= $row['success'] ? 'green' : 'red' ?>"> | |
| 121 | + | <?= $row['success'] ? t_default('acp.lattempt.success', 'Success') : t_default('acp.lattempt.failed', 'Failed') ?> | |
| 122 | + | </span> | |
| 123 | + | </td> | |
| 124 | + | </tr> | |
| 125 | + | <?php endforeach; ?> | |
| 126 | + | </tbody> | |
| 127 | + | </table> | |
| 128 | + | </div> | |
| 129 | + | <?php else: ?> | |
| 130 | + | <?php acp_empty(t_default('acp.lattempt.empty', 'No login attempts recorded yet.'), 'fa-lock'); ?> | |
| 131 | + | <?php endif; ?> | |
| 132 | + | </div> | |
| 133 | + | </section> |
| @@ -0,0 +1,239 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Mass Actions | |
| 4 | + | * Icon: fa-tasks | |
| 5 | + | * Group: Players | |
| 6 | + | * Order: 21 | |
| 7 | + | * Description: Apply points, premium days or a teleport to every account or character at once. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | $accountTotal = acp_count("SELECT COUNT(*) AS `c` FROM `accounts`;"); | |
| 16 | + | ||
| 17 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 18 | + | ||
| 19 | + | // -------------------------------------------------------- Mass points | |
| 20 | + | if (isset($_POST['points_delta'])) { | |
| 21 | + | $delta = intv($_POST['points_delta'] ?? 0); | |
| 22 | + | ||
| 23 | + | if ($delta === 0) { | |
| 24 | + | acp_flash_error(t('acp.mpts.zero_value')); | |
| 25 | + | acp_redirect('mass_actions'); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | db()->execute(" | |
| 29 | + | INSERT INTO `znote_accounts` (`account_id`, `ip`, `created`, `flag`) | |
| 30 | + | SELECT `a`.`id`, 0, ?, '' | |
| 31 | + | FROM `accounts` `a` | |
| 32 | + | LEFT JOIN `znote_accounts` `z` ON `z`.`account_id` = `a`.`id` | |
| 33 | + | WHERE `z`.`account_id` IS NULL; | |
| 34 | + | ", [time()]); | |
| 35 | + | ||
| 36 | + | $updated = db()->execute("UPDATE `znote_accounts` SET `points` = GREATEST(0, `points` + ?);", [$delta]); | |
| 37 | + | ||
| 38 | + | if ($updated !== false) { | |
| 39 | + | acp_log('accounts.mass_points', '', ['delta' => $delta, 'accounts' => $accountTotal]); | |
| 40 | + | acp_flash_success(t('acp.mpts.done', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'n' => $accountTotal])); | |
| 41 | + | } else { | |
| 42 | + | acp_flash_error(t('acp.mpts.failed')); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | acp_redirect('mass_actions'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // --------------------------------------------------- Mass premium days | |
| 49 | + | if (isset($_POST['premium_days'])) { | |
| 50 | + | $days = intv($_POST['premium_days'] ?? 0); | |
| 51 | + | ||
| 52 | + | if ($days <= 0) { | |
| 53 | + | acp_flash_error(t('acp.mprem.invalid_value')); | |
| 54 | + | acp_redirect('mass_actions'); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | $updated = user_accounts_add_premdays_all($days); | |
| 58 | + | ||
| 59 | + | if ($updated !== false) { | |
| 60 | + | acp_log('accounts.mass_premium_days', '', ['days' => $days, 'accounts' => $accountTotal]); | |
| 61 | + | acp_flash_success(t('acp.mprem.done', ['days' => $days, 'n' => $accountTotal])); | |
| 62 | + | } else { | |
| 63 | + | acp_flash_error(t('acp.mprem.not_supported')); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | acp_redirect('mass_actions'); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // ------------------------------------------------------------ Teleport | |
| 70 | + | if (isset($_POST['from'], $_POST['to'])) { | |
| 71 | + | $from = (string)$_POST['from']; | |
| 72 | + | $to = (string)$_POST['to']; | |
| 73 | + | ||
| 74 | + | $target = ''; | |
| 75 | + | $fail = false; | |
| 76 | + | ||
| 77 | + | if ($from === 'only') { | |
| 78 | + | $target = trim((string)($_POST['player_name'] ?? '')); | |
| 79 | + | if ($target === '' || !user_character_exist($target)) { | |
| 80 | + | acp_flash_error(t('acp.plr.err_invalid_teleport_char')); | |
| 81 | + | $fail = true; | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | if (!$fail) { | |
| 86 | + | $set = null; | |
| 87 | + | ||
| 88 | + | if ($to === 'home') { | |
| 89 | + | $set = '`posx`=0, `posy`=0, `posz`=0'; | |
| 90 | + | } elseif ($to === 'town') { | |
| 91 | + | $set = '`posx`=0, `posy`=0, `posz`=0, `town_id`=' . intv($_POST['town'] ?? 0); | |
| 92 | + | } elseif ($to === 'xyz') { | |
| 93 | + | $set = '`posx`=' . intv($_POST['x'] ?? 0) | |
| 94 | + | . ', `posy`=' . intv($_POST['y'] ?? 0) | |
| 95 | + | . ', `posz`=' . intv($_POST['z'] ?? 0); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Anything else would have produced "UPDATE players SET " and a | |
| 99 | + | // SQL error, so refuse instead of guessing. | |
| 100 | + | if ($set === null) { | |
| 101 | + | acp_flash_error(t('acp.plr.err_unknown_destination')); | |
| 102 | + | } else { | |
| 103 | + | if ($from === 'only') { | |
| 104 | + | db()->execute("UPDATE `players` SET {$set} WHERE `name` = ?;", [$target]); | |
| 105 | + | } else { | |
| 106 | + | db()->execute("UPDATE `players` SET {$set};"); | |
| 107 | + | } | |
| 108 | + | acp_log('player.teleport', $from === 'only' ? $target : 'ALL', ['destination' => $to]); | |
| 109 | + | acp_flash_success($from === 'only' | |
| 110 | + | ? t('acp.plr.tp_one_done') | |
| 111 | + | : t('acp.plr.tp_all_done')); | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | acp_redirect('mass_actions'); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ?> | |
| 119 | + | ||
| 120 | + | <div class="acp-stats"> | |
| 121 | + | <?php acp_stat(t('acp.mpts.stat_accounts'), $accountTotal, 'fa-users', acp_url('accounts'), 'blue'); ?> | |
| 122 | + | </div> | |
| 123 | + | ||
| 124 | + | <div class="acp-grid acp-grid--2"> | |
| 125 | + | ||
| 126 | + | <!-- --------------------------------------------------------- Mass points --> | |
| 127 | + | <section class="acp-card"> | |
| 128 | + | <header class="acp-card-head"> | |
| 129 | + | <h2><?= t('acp.mpts.title') ?></h2> | |
| 130 | + | <p><?= t('acp.mpts.sub') ?></p> | |
| 131 | + | </header> | |
| 132 | + | <div class="acp-card-body"> | |
| 133 | + | <div class="acp-flash acp-flash--error" style="margin-bottom:16px;"> | |
| 134 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 135 | + | <span><?= t('acp.mpts.warning', ['n' => $accountTotal]) ?></span> | |
| 136 | + | </div> | |
| 137 | + | ||
| 138 | + | <form method="post" data-confirm="<?= h(t('acp.mpts.confirm', ['n' => $accountTotal])) ?>"> | |
| 139 | + | <?= acp_csrf_field() ?> | |
| 140 | + | <div class="acp-field"> | |
| 141 | + | <label class="acp-label" for="points_delta"><?= t('acp.mpts.delta_label') ?></label> | |
| 142 | + | <input class="acp-input" id="points_delta" name="points_delta" type="number" step="1" placeholder="<?= h(t('acp.mpts.delta_placeholder')) ?>" required> | |
| 143 | + | <p class="acp-hint"><?= t('acp.mpts.delta_help') ?></p> | |
| 144 | + | </div> | |
| 145 | + | <div class="acp-actions"> | |
| 146 | + | <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-bolt"></i> <?= t('acp.mpts.submit_btn') ?></button> | |
| 147 | + | </div> | |
| 148 | + | </form> | |
| 149 | + | </div> | |
| 150 | + | </section> | |
| 151 | + | ||
| 152 | + | <!-- --------------------------------------------------- Mass premium days --> | |
| 153 | + | <section class="acp-card"> | |
| 154 | + | <header class="acp-card-head"> | |
| 155 | + | <h2><?= t('acp.mprem.title') ?></h2> | |
| 156 | + | <p><?= t('acp.mprem.sub') ?></p> | |
| 157 | + | </header> | |
| 158 | + | <div class="acp-card-body"> | |
| 159 | + | <div class="acp-flash acp-flash--error" style="margin-bottom:16px;"> | |
| 160 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 161 | + | <span><?= t('acp.mprem.warning', ['n' => $accountTotal]) ?></span> | |
| 162 | + | </div> | |
| 163 | + | ||
| 164 | + | <form method="post" data-confirm="<?= h(t('acp.mprem.confirm', ['n' => $accountTotal])) ?>"> | |
| 165 | + | <?= acp_csrf_field() ?> | |
| 166 | + | <div class="acp-field"> | |
| 167 | + | <label class="acp-label" for="premium_days"><?= t('acp.mprem.days_label') ?></label> | |
| 168 | + | <input class="acp-input" id="premium_days" name="premium_days" type="number" min="1" step="1" placeholder="<?= h(t('acp.mprem.days_placeholder')) ?>" required> | |
| 169 | + | <p class="acp-hint"><?= t('acp.mprem.days_help') ?></p> | |
| 170 | + | </div> | |
| 171 | + | <div class="acp-actions"> | |
| 172 | + | <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-star"></i> <?= t('acp.mprem.submit_btn') ?></button> | |
| 173 | + | </div> | |
| 174 | + | </form> | |
| 175 | + | </div> | |
| 176 | + | </section> | |
| 177 | + | ||
| 178 | + | </div> | |
| 179 | + | ||
| 180 | + | <!-- ------------------------------------------------------------ Teleport --> | |
| 181 | + | <section class="acp-card"> | |
| 182 | + | <header class="acp-card-head"> | |
| 183 | + | <h2><?= h(t('acp.plr.teleport_title')) ?></h2> | |
| 184 | + | <p><?= h(t('acp.plr.teleport_sub')) ?></p> | |
| 185 | + | </header> | |
| 186 | + | <div class="acp-card-body"> | |
| 187 | + | <form method="post" data-confirm="<?= h(t('acp.plr.teleport_confirm')) ?>"> | |
| 188 | + | <?= acp_csrf_field() ?> | |
| 189 | + | <div class="acp-row"> | |
| 190 | + | <div class="acp-field"> | |
| 191 | + | <label class="acp-label" for="tp_from"><?= h(t('acp.plr.who_label')) ?></label> | |
| 192 | + | <select class="acp-select" id="tp_from" name="from"> | |
| 193 | + | <option value="only"><?= h(t('acp.plr.tp_one_option')) ?></option> | |
| 194 | + | <option value="all"><?= h(t('acp.plr.tp_all_option')) ?></option> | |
| 195 | + | </select> | |
| 196 | + | </div> | |
| 197 | + | <div class="acp-field"> | |
| 198 | + | <label class="acp-label" for="player_name"><?= h(t('acp.plr.character_label')) ?></label> | |
| 199 | + | <input class="acp-input" id="player_name" name="player_name" placeholder="<?= h(t('acp.plr.tp_char_placeholder')) ?>"> | |
| 200 | + | </div> | |
| 201 | + | <div class="acp-field"> | |
| 202 | + | <label class="acp-label" for="tp_to"><?= h(t('acp.plr.destination_label')) ?></label> | |
| 203 | + | <select class="acp-select" id="tp_to" name="to"> | |
| 204 | + | <option value="home"><?= h(t('acp.plr.dest_home')) ?></option> | |
| 205 | + | <option value="town"><?= h(t('acp.plr.dest_town')) ?></option> | |
| 206 | + | <option value="xyz"><?= h(t('acp.plr.dest_xyz')) ?></option> | |
| 207 | + | </select> | |
| 208 | + | </div> | |
| 209 | + | </div> | |
| 210 | + | ||
| 211 | + | <div class="acp-row"> | |
| 212 | + | <div class="acp-field"> | |
| 213 | + | <label class="acp-label" for="tp_town"><?= h(t('acp.plr.town_label')) ?></label> | |
| 214 | + | <select class="acp-select" id="tp_town" name="town"> | |
| 215 | + | <?php foreach (($config['towns'] ?? []) as $tid => $tname): ?> | |
| 216 | + | <option value="<?= (int)$tid ?>"><?= h($tname) ?></option> | |
| 217 | + | <?php endforeach; ?> | |
| 218 | + | </select> | |
| 219 | + | </div> | |
| 220 | + | <div class="acp-field"> | |
| 221 | + | <label class="acp-label" for="tp_x">X</label> | |
| 222 | + | <input class="acp-input" id="tp_x" name="x" type="number" value="0"> | |
| 223 | + | </div> | |
| 224 | + | <div class="acp-field"> | |
| 225 | + | <label class="acp-label" for="tp_y">Y</label> | |
| 226 | + | <input class="acp-input" id="tp_y" name="y" type="number" value="0"> | |
| 227 | + | </div> | |
| 228 | + | <div class="acp-field"> | |
| 229 | + | <label class="acp-label" for="tp_z">Z</label> | |
| 230 | + | <input class="acp-input" id="tp_z" name="z" type="number" value="7"> | |
| 231 | + | </div> | |
| 232 | + | </div> | |
| 233 | + | ||
| 234 | + | <div class="acp-actions"> | |
| 235 | + | <button class="acp-btn" type="submit"><i class="fa fa-location-arrow"></i> <?= h(t('acp.plr.teleport_btn')) ?></button> | |
| 236 | + | </div> | |
| 237 | + | </form> | |
| 238 | + | </div> | |
| 239 | + | </section> |