| @@ -0,0 +1,444 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Accounts | |
| 4 | + | * Icon: fa-address-card-o | |
| 5 | + | * Group: Players | |
| 6 | + | * Order: 10 | |
| 7 | + | * Description: Search an account, see its characters, points and history. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | $isOthire = (znote_server_adapter()->accountIdentityColumn() === 'id'); | |
| 16 | + | $accNameCol = znote_server_adapter()->accountDisplayColumn(); | |
| 17 | + | ||
| 18 | + | $search = trim((string)($_GET['q'] ?? '')); | |
| 19 | + | $accountId = intv($_GET['id'] ?? 0); | |
| 20 | + | ||
| 21 | + | // --------------------------------------------------------------------------- | |
| 22 | + | // Actions | |
| 23 | + | // --------------------------------------------------------------------------- | |
| 24 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 25 | + | ||
| 26 | + | $id = intv($_POST['id'] ?? 0); | |
| 27 | + | $do = (string)($_POST['do'] ?? ''); | |
| 28 | + | ||
| 29 | + | if ($id <= 0) { | |
| 30 | + | acp_flash_error(t('acp.acc.no_selected')); | |
| 31 | + | acp_redirect('accounts'); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if ($do === 'points') { | |
| 35 | + | $delta = intv($_POST['points'] ?? 0); | |
| 36 | + | ||
| 37 | + | $row = db()->fetchOne("SELECT `points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1;", [$id]); | |
| 38 | + | if (!is_array($row)) { | |
| 39 | + | acp_flash_error(t('acp.acc.no_row')); | |
| 40 | + | } else { | |
| 41 | + | $new = max(0, (int)$row['points'] + $delta); | |
| 42 | + | db()->execute("UPDATE `znote_accounts` SET `points` = ? WHERE `account_id` = ?;", [$new, $id]); | |
| 43 | + | acp_log('account.points', '#' . $id, ['delta' => $delta, 'new_balance' => $new]); | |
| 44 | + | acp_flash_success(t('acp.acc.points_applied', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'new' => $new])); | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | acp_redirect('accounts', array('id' => $id)); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ------------------------------------------------------ Bulk (selected rows) | |
| 52 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['do'] ?? '') === 'bulk_points') { | |
| 53 | + | $ids = array_values(array_unique(array_filter(array_map('intv', (array)($_POST['ids'] ?? []))))); | |
| 54 | + | $delta = intv($_POST['bulk_points'] ?? 0); | |
| 55 | + | ||
| 56 | + | if (!$ids) { | |
| 57 | + | acp_flash_error(t_default('acp.acc.bulk_none_selected', 'Select at least one account first.')); | |
| 58 | + | acp_redirect('accounts', array('q' => $search)); | |
| 59 | + | } | |
| 60 | + | if ($delta === 0) { | |
| 61 | + | acp_flash_error(t('acp.mpts.zero_value')); | |
| 62 | + | acp_redirect('accounts', array('q' => $search)); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | $placeholders = implode(',', array_fill(0, count($ids), '?')); | |
| 66 | + | $updated = db()->execute(" | |
| 67 | + | UPDATE `znote_accounts` SET `points` = GREATEST(0, `points` + ?) WHERE `account_id` IN ({$placeholders}); | |
| 68 | + | ", array_merge([$delta], $ids)); | |
| 69 | + | ||
| 70 | + | if ($updated !== false) { | |
| 71 | + | acp_log('accounts.bulk_points', implode(',', $ids), ['delta' => $delta, 'count' => count($ids)]); | |
| 72 | + | acp_flash_success(t_default('acp.acc.bulk_points_done', '{delta} points applied to {n} account(s).', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'n' => count($ids)])); | |
| 73 | + | } else { | |
| 74 | + | acp_flash_error(t('acp.mpts.failed')); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | acp_redirect('accounts', array('q' => $search)); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // --------------------------------------------------------------------------- | |
| 81 | + | // One account | |
| 82 | + | // --------------------------------------------------------------------------- | |
| 83 | + | $account = null; | |
| 84 | + | if ($accountId > 0) { | |
| 85 | + | $account = db()->fetchOne(" | |
| 86 | + | SELECT `a`.`id`, {$accNameCol} AS `account_name`, `a`.`email`, | |
| 87 | + | `za`.`points`, `za`.`created`, `za`.`ip`, `za`.`flag`, `za`.`active_email` | |
| 88 | + | FROM `accounts` `a` | |
| 89 | + | LEFT JOIN `znote_accounts` `za` ON `za`.`account_id` = `a`.`id` | |
| 90 | + | WHERE `a`.`id` = ? | |
| 91 | + | LIMIT 1; | |
| 92 | + | ", [$accountId]); | |
| 93 | + | ||
| 94 | + | if (!is_array($account)) { | |
| 95 | + | acp_flash_error(t('acp.acc.no_account', ['id' => $accountId])); | |
| 96 | + | acp_redirect('accounts'); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | $characters = db()->fetchAll(" | |
| 100 | + | SELECT `id`, `name`, `level`, `vocation`, `group_id` | |
| 101 | + | FROM `players` | |
| 102 | + | WHERE `account_id` = ? | |
| 103 | + | ORDER BY `level` DESC; | |
| 104 | + | ", [$accountId]); | |
| 105 | + | $characters = is_array($characters) ? $characters : array(); | |
| 106 | + | ||
| 107 | + | $purchases = db()->fetchAll(" | |
| 108 | + | SELECT `type`, `itemid`, `count`, `points`, `time` | |
| 109 | + | FROM `znote_shop_logs` | |
| 110 | + | WHERE `account_id` = ? | |
| 111 | + | ORDER BY `id` DESC | |
| 112 | + | LIMIT 10; | |
| 113 | + | ", [$accountId]); | |
| 114 | + | $purchases = is_array($purchases) ? $purchases : array(); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // --------------------------------------------------------------------------- | |
| 118 | + | // Search / listing | |
| 119 | + | // --------------------------------------------------------------------------- | |
| 120 | + | const ACP_ACCOUNTS_PER_PAGE = 50; | |
| 121 | + | ||
| 122 | + | $results = array(); | |
| 123 | + | if ($account === null) { | |
| 124 | + | $where = ''; | |
| 125 | + | $params = []; | |
| 126 | + | if ($search !== '') { | |
| 127 | + | $like = '%' . $search . '%'; | |
| 128 | + | // Match the account name, its e-mail, or a character on it. | |
| 129 | + | if ($isOthire) { | |
| 130 | + | $where = "WHERE `a`.`id` = ? OR `a`.`email` LIKE ? OR `a`.`id` IN (SELECT `account_id` FROM `players` WHERE `name` LIKE ?)"; | |
| 131 | + | $params = [(int)$search, $like, $like]; | |
| 132 | + | } else { | |
| 133 | + | $where = "WHERE `a`.`name` LIKE ? OR `a`.`email` LIKE ? OR `a`.`id` IN (SELECT `account_id` FROM `players` WHERE `name` LIKE ?)"; | |
| 134 | + | $params = [$like, $like, $like]; | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | $totalRow = db()->fetchOne("SELECT COUNT(*) AS `n` FROM `accounts` `a` {$where};", $params); | |
| 139 | + | $total = is_array($totalRow) ? (int)$totalRow['n'] : 0; | |
| 140 | + | $pageCount = max(1, (int)ceil($total / ACP_ACCOUNTS_PER_PAGE)); | |
| 141 | + | $page = max(1, min($pageCount, intv($_GET['ap'] ?? 1))); | |
| 142 | + | $offset = ($page - 1) * ACP_ACCOUNTS_PER_PAGE; | |
| 143 | + | ||
| 144 | + | $results = db()->fetchAll(" | |
| 145 | + | SELECT `a`.`id`, {$accNameCol} AS `account_name`, `a`.`email`, | |
| 146 | + | `za`.`points`, `za`.`created`, | |
| 147 | + | (SELECT COUNT(*) FROM `players` `p` WHERE `p`.`account_id` = `a`.`id`) AS `characters` | |
| 148 | + | FROM `accounts` `a` | |
| 149 | + | LEFT JOIN `znote_accounts` `za` ON `za`.`account_id` = `a`.`id` | |
| 150 | + | {$where} | |
| 151 | + | ORDER BY `a`.`id` DESC | |
| 152 | + | LIMIT {$offset}, " . ACP_ACCOUNTS_PER_PAGE . "; | |
| 153 | + | ", $params); | |
| 154 | + | $results = is_array($results) ? $results : array(); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /** Keep the current search when building a paging link. */ | |
| 158 | + | function acp_accounts_page_url(int $page, string $search): string { | |
| 159 | + | $params = ['ap' => $page]; | |
| 160 | + | if ($search !== '') { | |
| 161 | + | $params['q'] = $search; | |
| 162 | + | } | |
| 163 | + | return acp_url('accounts', $params); | |
| 164 | + | } | |
| 165 | + | ?> | |
| 166 | + | ||
| 167 | + | <?php if ($account !== null): ?> | |
| 168 | + | ||
| 169 | + | <div class="acp-toolbar"> | |
| 170 | + | <div> | |
| 171 | + | <strong><?= h((string)$account['account_name']) ?></strong> | |
| 172 | + | <span class="acp-pill acp-pill--grey">#<?= (int)$account['id'] ?></span> | |
| 173 | + | </div> | |
| 174 | + | <div class="acp-actions is-tight"> | |
| 175 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', array('target' => '#' . (int)$account['id']))) ?>"> | |
| 176 | + | <i class="fa fa-history"></i> <?= t_default('acp.acc.view_history', 'Admin log for this account') ?> | |
| 177 | + | </a> | |
| 178 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('accounts')) ?>"> | |
| 179 | + | <i class="fa fa-arrow-left"></i> <?= t('acp.acc.all_accounts') ?> | |
| 180 | + | </a> | |
| 181 | + | </div> | |
| 182 | + | </div> | |
| 183 | + | ||
| 184 | + | <div class="acp-grid acp-grid--2"> | |
| 185 | + | ||
| 186 | + | <section class="acp-card"> | |
| 187 | + | <header class="acp-card-head"><h2><?= t('acp.acc.account_title') ?></h2></header> | |
| 188 | + | <div class="acp-card-body"> | |
| 189 | + | <dl class="acp-dl"> | |
| 190 | + | <dt><?= t('acp.acc.name') ?></dt><dd><?= h((string)$account['account_name']) ?></dd> | |
| 191 | + | <dt><?= t('acp.acc.email') ?></dt> | |
| 192 | + | <dd> | |
| 193 | + | <?= h((string)$account['email']) ?> | |
| 194 | + | <?php if (!empty($account['active_email'])): ?> | |
| 195 | + | <span class="acp-pill acp-pill--green"><?= t('acp.acc.verified') ?></span> | |
| 196 | + | <?php else: ?> | |
| 197 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.acc.unverified') ?></span> | |
| 198 | + | <?php endif; ?> | |
| 199 | + | </dd> | |
| 200 | + | <dt><?= t('acp.acc.registered') ?></dt> | |
| 201 | + | <dd><?= !empty($account['created']) ? h(getClock((int)$account['created'], true)) : '—' ?></dd> | |
| 202 | + | <dt><?= t('acp.acc.last_ip') ?></dt> | |
| 203 | + | <dd><?= !empty($account['ip']) ? '<code>' . h(long2ip((int)$account['ip'])) . '</code>' : '—' ?></dd> | |
| 204 | + | <dt><?= t('acp.acc.country') ?></dt> | |
| 205 | + | <dd><?= !empty($account['flag']) ? h((string)$account['flag']) : '—' ?></dd> | |
| 206 | + | <dt><?= t('acp.acc.shop_points') ?></dt> | |
| 207 | + | <dd><strong><?= number_format((int)($account['points'] ?? 0)) ?></strong></dd> | |
| 208 | + | </dl> | |
| 209 | + | </div> | |
| 210 | + | </section> | |
| 211 | + | ||
| 212 | + | <section class="acp-card"> | |
| 213 | + | <header class="acp-card-head"> | |
| 214 | + | <h2><?= t('acp.acc.adjust_points') ?></h2> | |
| 215 | + | <p><?= t('acp.acc.adjust_sub') ?></p> | |
| 216 | + | </header> | |
| 217 | + | <div class="acp-card-body"> | |
| 218 | + | <form method="post"> | |
| 219 | + | <?= acp_csrf_field() ?> | |
| 220 | + | <input type="hidden" name="do" value="points"> | |
| 221 | + | <input type="hidden" name="id" value="<?= (int)$account['id'] ?>"> | |
| 222 | + | <div class="acp-field"> | |
| 223 | + | <label class="acp-label" for="points"><?= t('acp.acc.amount') ?></label> | |
| 224 | + | <input class="acp-input" id="points" name="points" type="number" value="0" required> | |
| 225 | + | <p class="acp-hint"><?= t('acp.acc.balance_hint') ?></p> | |
| 226 | + | </div> | |
| 227 | + | <div class="acp-actions"> | |
| 228 | + | <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-diamond"></i> <?= t('acp.acc.apply') ?></button> | |
| 229 | + | </div> | |
| 230 | + | </form> | |
| 231 | + | ||
| 232 | + | <hr> | |
| 233 | + | <p class="is-muted" style="font-size:12.5px;"> | |
| 234 | + | <?= t('acp.acc.other_tools', [ | |
| 235 | + | 'link' => '<a href="' . h(acp_url('players')) . '">' . t('acp.acc.player_tools_link') . '</a>', | |
| 236 | + | ]) ?> | |
| 237 | + | </p> | |
| 238 | + | </div> | |
| 239 | + | </section> | |
| 240 | + | </div> | |
| 241 | + | ||
| 242 | + | <section class="acp-card"> | |
| 243 | + | <header class="acp-card-head"> | |
| 244 | + | <h2><?= t('acp.acc.characters') ?></h2> | |
| 245 | + | <p><?= t('acp.acc.n_on_account', ['n' => count($characters)]) ?></p> | |
| 246 | + | </header> | |
| 247 | + | <div class="acp-card-body is-flush"> | |
| 248 | + | <?php if ($characters): ?> | |
| 249 | + | <div class="acp-table-wrap"> | |
| 250 | + | <table class="acp-table"> | |
| 251 | + | <thead><tr><th><?= t('acp.acc.col_name') ?></th><th><?= t('acp.acc.col_vocation') ?></th><th class="is-num"><?= t('acp.acc.col_level') ?></th><th><?= t('acp.acc.col_group') ?></th><th class="is-num"> </th></tr></thead> | |
| 252 | + | <tbody> | |
| 253 | + | <?php foreach ($characters as $char): ?> | |
| 254 | + | <tr> | |
| 255 | + | <td> | |
| 256 | + | <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$char['name']))) ?>" target="_blank" rel="noopener"> | |
| 257 | + | <?= h((string)$char['name']) ?> | |
| 258 | + | </a> | |
| 259 | + | </td> | |
| 260 | + | <td class="is-muted"><?= h(vocation_id_to_name((int)$char['vocation'])) ?></td> | |
| 261 | + | <td class="is-num"><?= (int)$char['level'] ?></td> | |
| 262 | + | <td> | |
| 263 | + | <?php if ((int)$char['group_id'] > 1): ?> | |
| 264 | + | <span class="acp-pill acp-pill--red"><?= t('acp.acc.staff') ?></span> | |
| 265 | + | <?php else: ?> | |
| 266 | + | <span class="is-muted"><?= t('acp.acc.player') ?></span> | |
| 267 | + | <?php endif; ?> | |
| 268 | + | </td> | |
| 269 | + | <td class="is-num is-nowrap"> | |
| 270 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('skills', array('name' => (string)$char['name']))) ?>"> | |
| 271 | + | <i class="fa fa-bolt"></i> <?= t('acp.acc.skills') ?> | |
| 272 | + | </a> | |
| 273 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', array('target' => (string)$char['name']))) ?>" title="<?= h(t_default('acp.acc.view_history', 'Admin log for this account')) ?>"> | |
| 274 | + | <i class="fa fa-history"></i> | |
| 275 | + | </a> | |
| 276 | + | </td> | |
| 277 | + | </tr> | |
| 278 | + | <?php endforeach; ?> | |
| 279 | + | </tbody> | |
| 280 | + | </table> | |
| 281 | + | </div> | |
| 282 | + | <?php else: ?> | |
| 283 | + | <?php acp_empty(t('acp.acc.no_characters'), 'fa-user-o'); ?> | |
| 284 | + | <?php endif; ?> | |
| 285 | + | </div> | |
| 286 | + | </section> | |
| 287 | + | ||
| 288 | + | <section class="acp-card"> | |
| 289 | + | <header class="acp-card-head"> | |
| 290 | + | <h2><?= t('acp.acc.recent_purchases') ?></h2> | |
| 291 | + | <p><?= t('acp.acc.last_10') ?></p> | |
| 292 | + | </header> | |
| 293 | + | <div class="acp-card-body is-flush"> | |
| 294 | + | <?php if ($purchases): ?> | |
| 295 | + | <div class="acp-table-wrap"> | |
| 296 | + | <table class="acp-table"> | |
| 297 | + | <thead><tr><th><?= t('acp.acc.col_date') ?></th><th><?= t('acp.acc.col_type') ?></th><th class="is-num"><?= t('acp.acc.col_count') ?></th><th class="is-num"><?= t('acp.acc.col_points') ?></th></tr></thead> | |
| 298 | + | <tbody> | |
| 299 | + | <?php | |
| 300 | + | $types = array( | |
| 301 | + | 1 => t('acp.acc.type_item'), 2 => t('acp.acc.type_premium'), 3 => t('acp.acc.type_gender'), | |
| 302 | + | 4 => t('acp.acc.type_name'), 5 => t('acp.acc.type_outfit'), 6 => t('acp.acc.type_mount'), | |
| 303 | + | 7 => t('acp.acc.type_custom'), | |
| 304 | + | ); | |
| 305 | + | foreach ($purchases as $buy): ?> | |
| 306 | + | <tr> | |
| 307 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$buy['time'], true)) ?></td> | |
| 308 | + | <td><?= h($types[(int)$buy['type']] ?? t('acp.acc.type_unknown')) ?></td> | |
| 309 | + | <td class="is-num"><?= (int)$buy['count'] ?></td> | |
| 310 | + | <td class="is-num"><?= (int)$buy['points'] ?></td> | |
| 311 | + | </tr> | |
| 312 | + | <?php endforeach; ?> | |
| 313 | + | </tbody> | |
| 314 | + | </table> | |
| 315 | + | </div> | |
| 316 | + | <?php else: ?> | |
| 317 | + | <?php acp_empty(t('acp.acc.never_bought'), 'fa-shopping-cart'); ?> | |
| 318 | + | <?php endif; ?> | |
| 319 | + | </div> | |
| 320 | + | </section> | |
| 321 | + | ||
| 322 | + | <?php else: ?> | |
| 323 | + | ||
| 324 | + | <div class="acp-toolbar"> | |
| 325 | + | <form method="get" style="display:flex;gap:8px;flex:1 1 340px;max-width:520px;"> | |
| 326 | + | <input type="hidden" name="p" value="accounts"> | |
| 327 | + | <input class="acp-input" type="search" name="q" value="<?= h($search) ?>" | |
| 328 | + | placeholder="<?= h(t('acp.acc.search_placeholder')) ?>" autofocus> | |
| 329 | + | <button class="acp-btn" type="submit"><i class="fa fa-search"></i> <?= t('acp.acc.search') ?></button> | |
| 330 | + | <?php if ($search !== ''): ?> | |
| 331 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('accounts')) ?>"><?= t('acp.acc.clear') ?></a> | |
| 332 | + | <?php endif; ?> | |
| 333 | + | </form> | |
| 334 | + | <span class="is-muted"> | |
| 335 | + | <?= $search !== '' ? t('acp.acc.match_count', ['n' => $total]) : t_default('acp.acc.total_count', '{n} accounts', ['n' => $total]) ?> | |
| 336 | + | <?php if ($pageCount > 1): ?> | |
| 337 | + | · <?= t('acp.lay.page_of', ['page' => (int)$page, 'pageCount' => (int)$pageCount]) ?> | |
| 338 | + | <?php endif; ?> | |
| 339 | + | </span> | |
| 340 | + | </div> | |
| 341 | + | ||
| 342 | + | <section class="acp-card"> | |
| 343 | + | <header class="acp-card-head"> | |
| 344 | + | <h2><?= $search !== '' ? t('acp.acc.search_results') : t('acp.acc.newest_accounts') ?></h2> | |
| 345 | + | </header> | |
| 346 | + | <div class="acp-card-body is-flush"> | |
| 347 | + | <?php if ($results): ?> | |
| 348 | + | <form id="acpAccBulkForm" method="post"> | |
| 349 | + | <?= acp_csrf_field() ?> | |
| 350 | + | <input type="hidden" name="do" value="bulk_points"> | |
| 351 | + | <input type="hidden" name="q" value="<?= h($search) ?>"> | |
| 352 | + | </form> | |
| 353 | + | ||
| 354 | + | <div class="acp-toolbar" id="acpAccBulkBar" hidden> | |
| 355 | + | <span class="is-muted"><span id="acpAccBulkCount">0</span> <?= h(t_default('acp.acc.bulk_selected', 'selected')) ?></span> | |
| 356 | + | <div class="acp-actions is-tight"> | |
| 357 | + | <input class="acp-input" form="acpAccBulkForm" name="bulk_points" type="number" step="1" | |
| 358 | + | placeholder="<?= h(t_default('acp.acc.bulk_points_placeholder', '+/- points')) ?>" style="width:120px;"> | |
| 359 | + | <button type="submit" form="acpAccBulkForm" class="acp-btn acp-btn--sm"> | |
| 360 | + | <i class="fa fa-diamond"></i> <?= h(t_default('acp.acc.bulk_apply', 'Apply to selected')) ?> | |
| 361 | + | </button> | |
| 362 | + | </div> | |
| 363 | + | </div> | |
| 364 | + | ||
| 365 | + | <div class="acp-table-wrap"> | |
| 366 | + | <table class="acp-table" data-sortable> | |
| 367 | + | <thead> | |
| 368 | + | <tr><th><input type="checkbox" id="acpAccBulkAll" aria-label="<?= h(t_default('acp.acc.bulk_select_all', 'Select all')) ?>"></th><th>#</th><th><?= t('acp.acc.col_account') ?></th><th><?= t('acp.acc.col_email') ?></th><th class="is-num"><?= t('acp.acc.col_chars') ?></th><th class="is-num"><?= t('acp.acc.col_points') ?></th><th><?= t('acp.acc.col_registered') ?></th><th class="is-num"> </th></tr> | |
| 369 | + | </thead> | |
| 370 | + | <tbody> | |
| 371 | + | <?php foreach ($results as $row): ?> | |
| 372 | + | <tr> | |
| 373 | + | <td><input type="checkbox" class="acp-acc-bulk-check" form="acpAccBulkForm" name="ids[]" value="<?= (int)$row['id'] ?>"></td> | |
| 374 | + | <td class="is-muted"><?= (int)$row['id'] ?></td> | |
| 375 | + | <td><?= h((string)$row['account_name']) ?></td> | |
| 376 | + | <td class="is-muted"><?= h((string)$row['email']) ?></td> | |
| 377 | + | <td class="is-num"><?= (int)$row['characters'] ?></td> | |
| 378 | + | <td class="is-num"><?= number_format((int)($row['points'] ?? 0)) ?></td> | |
| 379 | + | <td class="is-nowrap is-muted"><?= !empty($row['created']) ? h(getClock((int)$row['created'], true)) : '—' ?></td> | |
| 380 | + | <td class="is-num"> | |
| 381 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('accounts', array('id' => (int)$row['id']))) ?>"> | |
| 382 | + | <i class="fa fa-eye"></i> <?= t('acp.acc.open') ?> | |
| 383 | + | </a> | |
| 384 | + | </td> | |
| 385 | + | </tr> | |
| 386 | + | <?php endforeach; ?> | |
| 387 | + | </tbody> | |
| 388 | + | </table> | |
| 389 | + | </div> | |
| 390 | + | ||
| 391 | + | <script> | |
| 392 | + | (function () { | |
| 393 | + | var all = document.getElementById('acpAccBulkAll'); | |
| 394 | + | var boxes = Array.prototype.slice.call(document.querySelectorAll('.acp-acc-bulk-check')); | |
| 395 | + | var bar = document.getElementById('acpAccBulkBar'); | |
| 396 | + | var count = document.getElementById('acpAccBulkCount'); | |
| 397 | + | if (!all || !bar) return; | |
| 398 | + | ||
| 399 | + | function refresh() { | |
| 400 | + | var checked = boxes.filter(function (b) { return b.checked; }); | |
| 401 | + | bar.hidden = checked.length === 0; | |
| 402 | + | if (count) count.textContent = checked.length; | |
| 403 | + | all.checked = checked.length > 0 && checked.length === boxes.length; | |
| 404 | + | all.indeterminate = checked.length > 0 && checked.length < boxes.length; | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | all.addEventListener('change', function () { | |
| 408 | + | boxes.forEach(function (b) { b.checked = all.checked; }); | |
| 409 | + | refresh(); | |
| 410 | + | }); | |
| 411 | + | boxes.forEach(function (b) { b.addEventListener('change', refresh); }); | |
| 412 | + | })(); | |
| 413 | + | </script> | |
| 414 | + | <?php else: ?> | |
| 415 | + | <?php acp_empty($search !== '' ? t('acp.acc.no_match', ['search' => $search]) : t('acp.acc.no_accounts_yet'), 'fa-address-card-o'); ?> | |
| 416 | + | <?php endif; ?> | |
| 417 | + | </div> | |
| 418 | + | </section> | |
| 419 | + | ||
| 420 | + | <?php if ($pageCount > 1): ?> | |
| 421 | + | <nav class="acp-actions" style="justify-content:center;margin:18px 0 24px;" aria-label="<?= h(t_default('acp.pagination_label', 'Pages')) ?>"> | |
| 422 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page <= 1 ? ' is-disabled' : '' ?>" | |
| 423 | + | href="<?= h(acp_accounts_page_url(max(1, $page - 1), $search)) ?>" | |
| 424 | + | <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>> | |
| 425 | + | <i class="fa fa-angle-left"></i> <?= t('acp.lay.previous') ?> | |
| 426 | + | </a> | |
| 427 | + | ||
| 428 | + | <?php for ($p = 1; $p <= $pageCount; $p++): ?> | |
| 429 | + | <?php if ($p === $page): ?> | |
| 430 | + | <span class="acp-btn acp-btn--sm"><?= $p ?></span> | |
| 431 | + | <?php else: ?> | |
| 432 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_accounts_page_url($p, $search)) ?>"><?= $p ?></a> | |
| 433 | + | <?php endif; ?> | |
| 434 | + | <?php endfor; ?> | |
| 435 | + | ||
| 436 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page >= $pageCount ? ' is-disabled' : '' ?>" | |
| 437 | + | href="<?= h(acp_accounts_page_url(min($pageCount, $page + 1), $search)) ?>" | |
| 438 | + | <?= $page >= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>> | |
| 439 | + | <?= t('acp.lay.next') ?> <i class="fa fa-angle-right"></i> | |
| 440 | + | </a> | |
| 441 | + | </nav> | |
| 442 | + | <?php endif; ?> | |
| 443 | + | ||
| 444 | + | <?php endif; ?> |
| @@ -0,0 +1,237 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Admin Log | |
| 4 | + | * Icon: fa-history | |
| 5 | + | * Group: Overview | |
| 6 | + | * Order: 30 | |
| 7 | + | * Description: Who did what from the admin panel, and when. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function acp_log_action_label(string $action): string { | |
| 16 | + | $key = 'acp.log.action.' . $action; | |
| 17 | + | $label = t($key); | |
| 18 | + | if ($label !== $key) { | |
| 19 | + | return $label; | |
| 20 | + | } | |
| 21 | + | $parts = explode('.', $action, 2); | |
| 22 | + | $words = str_replace(array('_', '-'), ' ', end($parts)); | |
| 23 | + | return $words !== '' ? ucfirst($words) : $action; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | $hasTable = acp_log_table_exists(); | |
| 27 | + | ||
| 28 | + | $days = (string)($_GET['days'] ?? 'all'); | |
| 29 | + | if (!in_array($days, array('1', '7', '30', '90', 'all'), true)) { | |
| 30 | + | $days = 'all'; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | $target = trim((string)($_GET['target'] ?? '')); | |
| 34 | + | $q = $target !== '' ? $target : trim((string)($_GET['q'] ?? '')); | |
| 35 | + | $action = trim((string)($_GET['action'] ?? '')); | |
| 36 | + | $page = max(1, intv($_GET['page'] ?? 1)); | |
| 37 | + | $perPage = 40; | |
| 38 | + | ||
| 39 | + | $where = array(); | |
| 40 | + | $params = array(); | |
| 41 | + | if ($hasTable) { | |
| 42 | + | if ($days !== 'all') { | |
| 43 | + | $where[] = "`created` >= ?"; | |
| 44 | + | $params[] = time() - ((int)$days * 86400); | |
| 45 | + | } | |
| 46 | + | if ($target !== '') { | |
| 47 | + | $where[] = "`target` = ?"; | |
| 48 | + | $params[] = $target; | |
| 49 | + | } elseif ($q !== '') { | |
| 50 | + | $like = '%' . $q . '%'; | |
| 51 | + | $where[] = "(`target` LIKE ? OR `admin_name` LIKE ? OR `action` LIKE ? OR `details` LIKE ?)"; | |
| 52 | + | $params[] = $like; | |
| 53 | + | $params[] = $like; | |
| 54 | + | $params[] = $like; | |
| 55 | + | $params[] = $like; | |
| 56 | + | } | |
| 57 | + | if ($action !== '') { | |
| 58 | + | $where[] = "`action` = ?"; | |
| 59 | + | $params[] = $action; | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; | |
| 63 | + | ||
| 64 | + | $total = $hasTable ? acp_count("SELECT COUNT(*) AS `c` FROM `znote_admin_log` {$whereSql};", $params) : 0; | |
| 65 | + | $totalPages = max(1, (int)ceil($total / $perPage)); | |
| 66 | + | $page = min($page, $totalPages); | |
| 67 | + | $offset = ($page - 1) * $perPage; | |
| 68 | + | ||
| 69 | + | $rows = array(); | |
| 70 | + | if ($hasTable) { | |
| 71 | + | $rows = db()->fetchAll(" | |
| 72 | + | SELECT `id`, `admin_id`, `admin_name`, `action`, `target`, `details`, `ip`, `created` | |
| 73 | + | FROM `znote_admin_log` | |
| 74 | + | {$whereSql} | |
| 75 | + | ORDER BY `id` DESC | |
| 76 | + | LIMIT {$offset}, {$perPage}; | |
| 77 | + | ", $params); | |
| 78 | + | $rows = is_array($rows) ? $rows : array(); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | $actionOptions = array(); | |
| 82 | + | if ($hasTable) { | |
| 83 | + | $actionRows = db()->fetchAll("SELECT DISTINCT `action` FROM `znote_admin_log` ORDER BY `action` ASC;"); | |
| 84 | + | if (is_array($actionRows)) { | |
| 85 | + | foreach ($actionRows as $row) { | |
| 86 | + | $actionOptions[] = (string)$row['action']; | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | $eventsToday = $hasTable ? acp_count("SELECT COUNT(*) AS `c` FROM `znote_admin_log` WHERE `created` >= ?;", [time() - 86400]) : 0; | |
| 92 | + | $eventsAll = $hasTable ? acp_count("SELECT COUNT(*) AS `c` FROM `znote_admin_log`;") : 0; | |
| 93 | + | $adminsActive = $hasTable ? acp_count("SELECT COUNT(DISTINCT `admin_id`) AS `c` FROM `znote_admin_log` WHERE `created` >= ?;", [time() - 30 * 86400]) : 0; | |
| 94 | + | ||
| 95 | + | function acp_log_query(array $overrides = array()): array { | |
| 96 | + | global $q, $action, $days; | |
| 97 | + | return array_filter(array_merge( | |
| 98 | + | array('q' => $q, 'action' => $action, 'days' => $days === 'all' ? null : $days), | |
| 99 | + | $overrides | |
| 100 | + | ), static fn($v) => $v !== null && $v !== ''); | |
| 101 | + | } | |
| 102 | + | ?> | |
| 103 | + | ||
| 104 | + | <?php if (!$hasTable): ?> | |
| 105 | + | <div class="acp-flash acp-flash--error"> | |
| 106 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 107 | + | <span> | |
| 108 | + | <?= t('acp.log.table_missing', [ | |
| 109 | + | 'table' => '<code>znote_admin_log</code>', | |
| 110 | + | 'file' => '<code>SQL/migrations/2.0.0_admin_log.sql</code>', | |
| 111 | + | ]) ?> | |
| 112 | + | </span> | |
| 113 | + | </div> | |
| 114 | + | <?php endif; ?> | |
| 115 | + | ||
| 116 | + | <div class="acp-stats"> | |
| 117 | + | <?php | |
| 118 | + | acp_stat(t('acp.log.stat_today'), $eventsToday, 'fa-clock-o', null, 'blue'); | |
| 119 | + | acp_stat(t('acp.log.stat_total'), $eventsAll, 'fa-database', null, 'purple'); | |
| 120 | + | acp_stat(t('acp.log.stat_admins'), $adminsActive, 'fa-users', null, 'teal'); | |
| 121 | + | ?> | |
| 122 | + | </div> | |
| 123 | + | ||
| 124 | + | <section class="acp-card"> | |
| 125 | + | <header class="acp-card-head"> | |
| 126 | + | <h2><?= t('acp.log.title') ?></h2> | |
| 127 | + | <p><?= t('acp.log.sub') ?></p> | |
| 128 | + | </header> | |
| 129 | + | <div class="acp-card-body"> | |
| 130 | + | <form method="get" class="acp-row"> | |
| 131 | + | <input type="hidden" name="p" value="adminlog"> | |
| 132 | + | <div class="acp-field" style="flex:2;min-width:220px;"> | |
| 133 | + | <label class="acp-label" for="q"><?= t('acp.log.search_label') ?></label> | |
| 134 | + | <input class="acp-input" id="q" name="q" value="<?= h($q) ?>" placeholder="<?= h(t('acp.log.search_placeholder')) ?>"> | |
| 135 | + | </div> | |
| 136 | + | <div class="acp-field"> | |
| 137 | + | <label class="acp-label" for="action"><?= t('acp.log.action_label') ?></label> | |
| 138 | + | <select class="acp-select" id="action" name="action"> | |
| 139 | + | <option value=""><?= t('acp.log.all_actions') ?></option> | |
| 140 | + | <?php foreach ($actionOptions as $opt): ?> | |
| 141 | + | <option value="<?= h($opt) ?>" <?= $opt === $action ? 'selected' : '' ?>><?= h(acp_log_action_label($opt)) ?></option> | |
| 142 | + | <?php endforeach; ?> | |
| 143 | + | </select> | |
| 144 | + | </div> | |
| 145 | + | <div class="acp-field"> | |
| 146 | + | <label class="acp-label" for="days"><?= t('acp.log.period_label') ?></label> | |
| 147 | + | <select class="acp-select" id="days" name="days"> | |
| 148 | + | <?php foreach (array('1' => t('acp.vis.today'), '7' => t('acp.vis.7days'), '30' => t('acp.vis.30days'), '90' => t('acp.vis.90days'), 'all' => t('acp.log.all_time')) as $val => $label): ?> | |
| 149 | + | <option value="<?= h($val) ?>" <?= $val === $days ? 'selected' : '' ?>><?= h($label) ?></option> | |
| 150 | + | <?php endforeach; ?> | |
| 151 | + | </select> | |
| 152 | + | </div> | |
| 153 | + | <div class="acp-actions"> | |
| 154 | + | <button class="acp-btn" type="submit"><i class="fa fa-filter"></i> <?= t('acp.log.filter') ?></button> | |
| 155 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('adminlog')) ?>"><?= t('acp.log.clear') ?></a> | |
| 156 | + | </div> | |
| 157 | + | </form> | |
| 158 | + | </div> | |
| 159 | + | </section> | |
| 160 | + | ||
| 161 | + | <section class="acp-card"> | |
| 162 | + | <div class="acp-card-body is-flush"> | |
| 163 | + | <?php if ($rows): ?> | |
| 164 | + | <div class="acp-table-wrap"> | |
| 165 | + | <table class="acp-table" data-sortable> | |
| 166 | + | <thead> | |
| 167 | + | <tr> | |
| 168 | + | <th><?= t('acp.log.col_date') ?></th> | |
| 169 | + | <th><?= t('acp.log.col_admin') ?></th> | |
| 170 | + | <th><?= t('acp.log.col_action') ?></th> | |
| 171 | + | <th><?= t('acp.log.col_target') ?></th> | |
| 172 | + | <th><?= t('acp.log.col_details') ?></th> | |
| 173 | + | <th><?= t('acp.log.col_ip') ?></th> | |
| 174 | + | </tr> | |
| 175 | + | </thead> | |
| 176 | + | <tbody> | |
| 177 | + | <?php foreach ($rows as $row): | |
| 178 | + | $details = array(); | |
| 179 | + | if ((string)$row['details'] !== '') { | |
| 180 | + | $decoded = json_decode((string)$row['details'], true); | |
| 181 | + | if (is_array($decoded)) { | |
| 182 | + | $details = $decoded; | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ?> | |
| 186 | + | <tr> | |
| 187 | + | <td class="is-nowrap"><?= h(getClock((int)$row['created'], true, true)) ?></td> | |
| 188 | + | <td class="is-nowrap"> | |
| 189 | + | <?php if ((int)$row['admin_id'] > 0): ?> | |
| 190 | + | <a href="<?= h(acp_url('accounts', array('id' => (int)$row['admin_id']))) ?>"><?= h((string)$row['admin_name']) ?></a> | |
| 191 | + | <?php else: ?> | |
| 192 | + | <span class="is-muted"><?= h((string)$row['admin_name'] !== '' ? (string)$row['admin_name'] : '—') ?></span> | |
| 193 | + | <?php endif; ?> | |
| 194 | + | </td> | |
| 195 | + | <td><span class="acp-pill acp-pill--blue"><?= h(acp_log_action_label((string)$row['action'])) ?></span></td> | |
| 196 | + | <td><?= $row['target'] !== '' ? '<strong>' . h((string)$row['target']) . '</strong>' : '<span class="is-muted">—</span>' ?></td> | |
| 197 | + | <td class="is-muted"> | |
| 198 | + | <?php if ($details): ?> | |
| 199 | + | <?php | |
| 200 | + | $pairs = array(); | |
| 201 | + | foreach ($details as $k => $v) { | |
| 202 | + | if (is_array($v)) { | |
| 203 | + | $v = implode(', ', array_map('strval', $v)); | |
| 204 | + | } | |
| 205 | + | $pairs[] = '<code>' . h((string)$k) . '</code>: ' . h((string)$v); | |
| 206 | + | } | |
| 207 | + | echo implode('<br>', $pairs); | |
| 208 | + | ?> | |
| 209 | + | <?php else: ?> | |
| 210 | + | — | |
| 211 | + | <?php endif; ?> | |
| 212 | + | </td> | |
| 213 | + | <td class="is-nowrap is-muted"><code><?= h((string)$row['ip']) ?></code></td> | |
| 214 | + | </tr> | |
| 215 | + | <?php endforeach; ?> | |
| 216 | + | </tbody> | |
| 217 | + | </table> | |
| 218 | + | </div> | |
| 219 | + | ||
| 220 | + | <?php if ($totalPages > 1): ?> | |
| 221 | + | <div class="acp-toolbar"> | |
| 222 | + | <span class="is-muted"><?= t('acp.log.page_of', ['page' => $page, 'total' => $totalPages, 'n' => number_format($total)]) ?></span> | |
| 223 | + | <div class="acp-actions is-tight"> | |
| 224 | + | <?php if ($page > 1): ?> | |
| 225 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', acp_log_query(array('page' => (string)($page - 1))))) ?>"><?= t('common.previous') ?></a> | |
| 226 | + | <?php endif; ?> | |
| 227 | + | <?php if ($page < $totalPages): ?> | |
| 228 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('adminlog', acp_log_query(array('page' => (string)($page + 1))))) ?>"><?= t('common.next') ?></a> | |
| 229 | + | <?php endif; ?> | |
| 230 | + | </div> | |
| 231 | + | </div> | |
| 232 | + | <?php endif; ?> | |
| 233 | + | <?php else: ?> | |
| 234 | + | <?php acp_empty(t('acp.log.empty'), 'fa-history'); ?> | |
| 235 | + | <?php endif; ?> | |
| 236 | + | </div> | |
| 237 | + | </section> |
| @@ -0,0 +1,315 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Analytics | |
| 4 | + | * Icon: fa-bar-chart | |
| 5 | + | * Group: Overview | |
| 6 | + | * Order: 15 | |
| 7 | + | * Description: Accounts, activity, shop revenue and where your players come from. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | /* | |
| 11 | + | * Everything here is read-only and cached for a few minutes (engine/cache/acp_analytics), | |
| 12 | + | * so opening this page never costs more than one batch of queries per cache | |
| 13 | + | * window, no matter how many admins are looking at it. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | if (!defined('ACP_ROOT')) { | |
| 17 | + | http_response_code(403); | |
| 18 | + | die('Direct access denied.'); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | function acp_country_flag_emoji(string $code): string { | |
| 22 | + | $code = strtoupper($code); | |
| 23 | + | if (!preg_match('/^[A-Z]{2}$/', $code)) { | |
| 24 | + | return ''; | |
| 25 | + | } | |
| 26 | + | $flag = ''; | |
| 27 | + | foreach (str_split($code) as $letter) { | |
| 28 | + | $flag .= mb_chr(0x1F1E6 + (ord($letter) - 65), 'UTF-8'); | |
| 29 | + | } | |
| 30 | + | return $flag; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | function acp_analytics_period_count(string $table, string $column, int $since): int { | |
| 34 | + | if (!znote_table_exists($table) || !znote_column_exists($table, $column)) { | |
| 35 | + | return 0; | |
| 36 | + | } | |
| 37 | + | return acp_count("SELECT COUNT(*) AS `c` FROM `{$table}` WHERE `{$column}` >= ?;", [$since]); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** Recent lines in the PHP error log that came from a plugin hook. */ | |
| 41 | + | function acp_analytics_plugin_errors(int $sinceDays): int { | |
| 42 | + | $path = trim((string)(@ini_get('error_log') ?: '')); | |
| 43 | + | if ($path === '' || !is_file($path) || !is_readable($path)) { | |
| 44 | + | return 0; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | $handle = @fopen($path, 'rb'); | |
| 48 | + | if ($handle === false) { | |
| 49 | + | return 0; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | $cutoff = time() - ($sinceDays * 86400); | |
| 53 | + | $count = 0; | |
| 54 | + | $pattern = '/\[(\d{2}-\w{3}-\d{4} \d{2}:\d{2}:\d{2})[^\]]*\].*\[ZnoteX plugin\]/'; | |
| 55 | + | ||
| 56 | + | while (($line = fgets($handle)) !== false) { | |
| 57 | + | if (strpos($line, '[ZnoteX plugin]') === false) { | |
| 58 | + | continue; | |
| 59 | + | } | |
| 60 | + | if (preg_match($pattern, $line, $m)) { | |
| 61 | + | $ts = strtotime($m[1]); | |
| 62 | + | if ($ts !== false && $ts < $cutoff) { | |
| 63 | + | continue; | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | $count++; | |
| 67 | + | } | |
| 68 | + | fclose($handle); | |
| 69 | + | ||
| 70 | + | return $count; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | function znote_analytics_snapshot(): array { | |
| 74 | + | $now = time(); | |
| 75 | + | $day = 86400; | |
| 76 | + | ||
| 77 | + | $data = array(); | |
| 78 | + | ||
| 79 | + | // -------------------------------------------------------------- Accounts | |
| 80 | + | $data['accounts_24h'] = acp_analytics_period_count('znote_accounts', 'created', $now - $day); | |
| 81 | + | $data['accounts_7d'] = acp_analytics_period_count('znote_accounts', 'created', $now - 7 * $day); | |
| 82 | + | $data['accounts_30d'] = acp_analytics_period_count('znote_accounts', 'created', $now - 30 * $day); | |
| 83 | + | ||
| 84 | + | $data['accounts_daily'] = array(); | |
| 85 | + | if (znote_table_exists('znote_accounts')) { | |
| 86 | + | $rows = db()->fetchAll(" | |
| 87 | + | SELECT FLOOR((? - `created`) / {$day}) AS `days_ago`, COUNT(*) AS `c` | |
| 88 | + | FROM `znote_accounts` | |
| 89 | + | WHERE `created` >= ? | |
| 90 | + | GROUP BY `days_ago`; | |
| 91 | + | ", [$now, $now - 14 * $day]); | |
| 92 | + | $byDay = array(); | |
| 93 | + | foreach ((is_array($rows) ? $rows : array()) as $row) { | |
| 94 | + | $byDay[(int)$row['days_ago']] = (int)$row['c']; | |
| 95 | + | } | |
| 96 | + | for ($i = 13; $i >= 0; $i--) { | |
| 97 | + | $data['accounts_daily'][] = array('label' => date('M j', $now - $i * $day), 'count' => $byDay[$i] ?? 0); | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ---------------------------------------------------------- Active players | |
| 102 | + | $data['active_24h'] = acp_analytics_period_count('players', 'lastlogin', $now - $day); | |
| 103 | + | $data['active_7d'] = acp_analytics_period_count('players', 'lastlogin', $now - 7 * $day); | |
| 104 | + | $data['active_30d'] = acp_analytics_period_count('players', 'lastlogin', $now - 30 * $day); | |
| 105 | + | ||
| 106 | + | // New vs returning, last 30 days: a returning player logged in during the | |
| 107 | + | // window but their account existed before it started. | |
| 108 | + | $data['new_players_30d'] = 0; | |
| 109 | + | $data['returning_players_30d'] = 0; | |
| 110 | + | if (znote_table_exists('players') && znote_column_exists('players', 'lastlogin') && znote_table_exists('accounts') && znote_column_exists('accounts', 'created')) { | |
| 111 | + | $windowStart = $now - 30 * $day; | |
| 112 | + | $data['new_players_30d'] = acp_count(" | |
| 113 | + | SELECT COUNT(DISTINCT `p`.`id`) AS `c` | |
| 114 | + | FROM `players` `p` | |
| 115 | + | INNER JOIN `accounts` `a` ON `a`.`id` = `p`.`account_id` | |
| 116 | + | WHERE `p`.`lastlogin` >= ? AND `a`.`created` >= ?; | |
| 117 | + | ", [$windowStart, $windowStart]); | |
| 118 | + | $data['returning_players_30d'] = acp_count(" | |
| 119 | + | SELECT COUNT(DISTINCT `p`.`id`) AS `c` | |
| 120 | + | FROM `players` `p` | |
| 121 | + | INNER JOIN `accounts` `a` ON `a`.`id` = `p`.`account_id` | |
| 122 | + | WHERE `p`.`lastlogin` >= ? AND `a`.`created` < ?; | |
| 123 | + | ", [$windowStart, $windowStart]); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // ------------------------------------------------------------- Peak online | |
| 127 | + | $record = znote_record_get(); | |
| 128 | + | $data['peak_online'] = $record['players']; | |
| 129 | + | $data['peak_online_at'] = $record['time']; | |
| 130 | + | $data['online_now'] = znote_server_adapter()->onlineCount(); | |
| 131 | + | ||
| 132 | + | // --------------------------------------------------------- Shop / revenue | |
| 133 | + | $data['orders_30d'] = acp_analytics_period_count('znote_shop_orders', 'time', $now - 30 * $day); | |
| 134 | + | ||
| 135 | + | $data['revenue_30d'] = 0.0; | |
| 136 | + | $data['revenue_currency'] = ''; | |
| 137 | + | $data['payments_failed_30d'] = 0; | |
| 138 | + | $data['payments_pending_30d'] = 0; | |
| 139 | + | if (znote_table_exists('znote_payment_transactions')) { | |
| 140 | + | $since = $now - 30 * $day; | |
| 141 | + | $paid = db()->fetchOne(" | |
| 142 | + | SELECT COALESCE(SUM(`price`), 0) AS `total`, MAX(`currency`) AS `currency`, COUNT(*) AS `c` | |
| 143 | + | FROM `znote_payment_transactions` | |
| 144 | + | WHERE `credited` = 1 AND `created_at` >= ?; | |
| 145 | + | ", [$since]); | |
| 146 | + | if (is_array($paid)) { | |
| 147 | + | $data['revenue_30d'] = (float)$paid['total']; | |
| 148 | + | $data['revenue_currency'] = (string)($paid['currency'] ?? ''); | |
| 149 | + | } | |
| 150 | + | $data['payments_failed_30d'] = acp_count(" | |
| 151 | + | SELECT COUNT(*) AS `c` FROM `znote_payment_transactions` | |
| 152 | + | WHERE `credited` = 0 AND `created_at` >= ? | |
| 153 | + | AND `status` IN ('failed', 'declined', 'cancelled', 'expired', 'error', 'not_paid', 'not_approved'); | |
| 154 | + | ", [$since]); | |
| 155 | + | $data['payments_pending_30d'] = acp_count(" | |
| 156 | + | SELECT COUNT(*) AS `c` FROM `znote_payment_transactions` | |
| 157 | + | WHERE `credited` = 0 AND `created_at` >= ? | |
| 158 | + | AND `status` NOT IN ('failed', 'declined', 'cancelled', 'expired', 'error', 'not_paid', 'not_approved'); | |
| 159 | + | ", [$since]); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // -------------------------------------------------------------- Countries | |
| 163 | + | $data['top_countries'] = array(); | |
| 164 | + | if (znote_table_exists('znote_accounts') && znote_column_exists('znote_accounts', 'flag')) { | |
| 165 | + | $rows = db()->fetchAll(" | |
| 166 | + | SELECT `flag`, COUNT(*) AS `c` | |
| 167 | + | FROM `znote_accounts` | |
| 168 | + | WHERE `flag` <> '' | |
| 169 | + | GROUP BY `flag` | |
| 170 | + | ORDER BY `c` DESC | |
| 171 | + | LIMIT 10; | |
| 172 | + | "); | |
| 173 | + | $data['top_countries'] = is_array($rows) ? $rows : array(); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // ---------------------------------------------------------------- Tickets | |
| 177 | + | $data['tickets_open'] = acp_badge_helpdesk(); | |
| 178 | + | $data['tickets_30d'] = acp_analytics_period_count('znote_tickets', 'creation', $now - 30 * $day); | |
| 179 | + | ||
| 180 | + | // ---------------------------------------------------------- Plugin errors | |
| 181 | + | $data['plugin_errors_7d'] = acp_analytics_plugin_errors(7); | |
| 182 | + | ||
| 183 | + | return $data; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | $acp_analytics_cache = new Cache('engine/cache/acp_analytics'); | |
| 187 | + | $acp_analytics_cache->setExpiration(300); | |
| 188 | + | ||
| 189 | + | if (isset($_GET['refresh'])) { | |
| 190 | + | $acp_analytics_cache->delete(); | |
| 191 | + | acp_redirect('analytics'); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | if (!$acp_analytics_cache->hasExpired() && is_array($acp_analytics_cache->load())) { | |
| 195 | + | $stats = $acp_analytics_cache->load(); | |
| 196 | + | } else { | |
| 197 | + | $stats = znote_analytics_snapshot(); | |
| 198 | + | $acp_analytics_cache->setContent($stats); | |
| 199 | + | $acp_analytics_cache->save(); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | $maxDaily = max(1, ...array_map(static fn($d) => (int)$d['count'], $stats['accounts_daily'] ?: array(array('count' => 0)))); | |
| 203 | + | ?> | |
| 204 | + | ||
| 205 | + | <div class="acp-toolbar"> | |
| 206 | + | <div></div> | |
| 207 | + | <div class="acp-actions is-tight"> | |
| 208 | + | <a class="acp-btn" href="<?= h(acp_url('analytics', array('refresh' => 1))) ?>"><i class="fa fa-refresh"></i> <?= t_default('acp.analytics.refresh', 'Refresh now') ?></a> | |
| 209 | + | </div> | |
| 210 | + | </div> | |
| 211 | + | ||
| 212 | + | <div class="acp-stats"> | |
| 213 | + | <?php | |
| 214 | + | acp_stat(t_default('acp.analytics.accounts_24h', 'Accounts (24h)'), $stats['accounts_24h'], 'fa-user-plus', null, 'blue'); | |
| 215 | + | acp_stat(t_default('acp.analytics.accounts_7d', 'Accounts (7d)'), $stats['accounts_7d'], 'fa-user-plus', null, 'blue'); | |
| 216 | + | acp_stat(t_default('acp.analytics.accounts_30d', 'Accounts (30d)'), $stats['accounts_30d'], 'fa-user-plus', null, 'blue'); | |
| 217 | + | acp_stat(t_default('acp.analytics.online_now', 'Online now'), $stats['online_now'], 'fa-signal', null, 'teal'); | |
| 218 | + | acp_stat(t_default('acp.analytics.peak_online', 'Peak online (all-time)'), $stats['peak_online'], 'fa-line-chart', null, 'green'); | |
| 219 | + | ?> | |
| 220 | + | </div> | |
| 221 | + | ||
| 222 | + | <div class="acp-grid acp-grid--2"> | |
| 223 | + | ||
| 224 | + | <section class="acp-card"> | |
| 225 | + | <header class="acp-card-head"> | |
| 226 | + | <h2><?= t_default('acp.analytics.accounts_daily_title', 'New accounts, last 14 days') ?></h2> | |
| 227 | + | </header> | |
| 228 | + | <div class="acp-card-body"> | |
| 229 | + | <div class="acp-table-wrap"> | |
| 230 | + | <table class="acp-table"> | |
| 231 | + | <tbody> | |
| 232 | + | <?php foreach ($stats['accounts_daily'] as $day): ?> | |
| 233 | + | <tr> | |
| 234 | + | <td class="is-nowrap is-muted"><?= h($day['label']) ?></td> | |
| 235 | + | <td style="width:100%;"> | |
| 236 | + | <div style="background:var(--acp-accent, #3b82f6); height:10px; border-radius:3px; width:<?= (int)round($day['count'] / $maxDaily * 100) ?>%;"></div> | |
| 237 | + | </td> | |
| 238 | + | <td class="is-num is-nowrap"><?= (int)$day['count'] ?></td> | |
| 239 | + | </tr> | |
| 240 | + | <?php endforeach; ?> | |
| 241 | + | </tbody> | |
| 242 | + | </table> | |
| 243 | + | </div> | |
| 244 | + | </div> | |
| 245 | + | </section> | |
| 246 | + | ||
| 247 | + | <section class="acp-card"> | |
| 248 | + | <header class="acp-card-head"> | |
| 249 | + | <h2><?= t_default('acp.analytics.activity_title', 'Player activity') ?></h2> | |
| 250 | + | </header> | |
| 251 | + | <div class="acp-card-body is-flush"> | |
| 252 | + | <div class="acp-table-wrap"> | |
| 253 | + | <table class="acp-table"> | |
| 254 | + | <tbody> | |
| 255 | + | <tr><td><?= t_default('acp.analytics.active_24h', 'Active players (24h)') ?></td><td class="is-num"><?= (int)$stats['active_24h'] ?></td></tr> | |
| 256 | + | <tr><td><?= t_default('acp.analytics.active_7d', 'Active players (7d)') ?></td><td class="is-num"><?= (int)$stats['active_7d'] ?></td></tr> | |
| 257 | + | <tr><td><?= t_default('acp.analytics.active_30d', 'Active players (30d)') ?></td><td class="is-num"><?= (int)$stats['active_30d'] ?></td></tr> | |
| 258 | + | <tr><td><?= t_default('acp.analytics.new_players', 'New players (30d)') ?></td><td class="is-num"><?= (int)$stats['new_players_30d'] ?></td></tr> | |
| 259 | + | <tr><td><?= t_default('acp.analytics.returning_players', 'Returning players (30d)') ?></td><td class="is-num"><?= (int)$stats['returning_players_30d'] ?></td></tr> | |
| 260 | + | </tbody> | |
| 261 | + | </table> | |
| 262 | + | </div> | |
| 263 | + | </div> | |
| 264 | + | </section> | |
| 265 | + | </div> | |
| 266 | + | ||
| 267 | + | <div class="acp-grid acp-grid--3"> | |
| 268 | + | ||
| 269 | + | <section class="acp-card"> | |
| 270 | + | <header class="acp-card-head"><h2><?= t_default('acp.analytics.shop_title', 'Shop (30d)') ?></h2></header> | |
| 271 | + | <div class="acp-card-body is-flush"> | |
| 272 | + | <table class="acp-table"> | |
| 273 | + | <tbody> | |
| 274 | + | <tr><td><?= t_default('acp.analytics.orders', 'Orders delivered') ?></td><td class="is-num"><?= (int)$stats['orders_30d'] ?></td></tr> | |
| 275 | + | <tr><td><?= t_default('acp.analytics.revenue', 'Revenue') ?></td><td class="is-num"><?= number_format($stats['revenue_30d'], 2) ?> <?= h($stats['revenue_currency']) ?></td></tr> | |
| 276 | + | <tr><td><?= t_default('acp.analytics.payments_pending', 'Payments pending') ?></td><td class="is-num"><?= (int)$stats['payments_pending_30d'] ?></td></tr> | |
| 277 | + | <tr><td><?= t_default('acp.analytics.payments_failed', 'Payments failed') ?></td><td class="is-num"><span class="acp-pill <?= $stats['payments_failed_30d'] > 0 ? 'acp-pill--red' : 'acp-pill--green' ?>"><?= (int)$stats['payments_failed_30d'] ?></span></td></tr> | |
| 278 | + | </tbody> | |
| 279 | + | </table> | |
| 280 | + | </div> | |
| 281 | + | </section> | |
| 282 | + | ||
| 283 | + | <section class="acp-card"> | |
| 284 | + | <header class="acp-card-head"><h2><?= t_default('acp.analytics.top_countries', 'Top countries') ?></h2></header> | |
| 285 | + | <div class="acp-card-body is-flush"> | |
| 286 | + | <?php if ($stats['top_countries']): ?> | |
| 287 | + | <table class="acp-table"> | |
| 288 | + | <tbody> | |
| 289 | + | <?php foreach ($stats['top_countries'] as $row): ?> | |
| 290 | + | <tr> | |
| 291 | + | <td><?= h(acp_country_flag_emoji((string)$row['flag'])) ?> <?= h(strtoupper((string)$row['flag'])) ?></td> | |
| 292 | + | <td class="is-num"><?= (int)$row['c'] ?></td> | |
| 293 | + | </tr> | |
| 294 | + | <?php endforeach; ?> | |
| 295 | + | </tbody> | |
| 296 | + | </table> | |
| 297 | + | <?php else: ?> | |
| 298 | + | <?php acp_empty(t_default('acp.analytics.no_countries', 'No data yet.'), 'fa-globe'); ?> | |
| 299 | + | <?php endif; ?> | |
| 300 | + | </div> | |
| 301 | + | </section> | |
| 302 | + | ||
| 303 | + | <section class="acp-card"> | |
| 304 | + | <header class="acp-card-head"><h2><?= t_default('acp.analytics.support_title', 'Support & health') ?></h2></header> | |
| 305 | + | <div class="acp-card-body is-flush"> | |
| 306 | + | <table class="acp-table"> | |
| 307 | + | <tbody> | |
| 308 | + | <tr><td><?= t_default('acp.analytics.tickets_open', 'Open tickets') ?></td><td class="is-num"><?= (int)$stats['tickets_open'] ?></td></tr> | |
| 309 | + | <tr><td><?= t_default('acp.analytics.tickets_30d', 'Tickets opened (30d)') ?></td><td class="is-num"><?= (int)$stats['tickets_30d'] ?></td></tr> | |
| 310 | + | <tr><td><?= t_default('acp.analytics.plugin_errors', 'Plugin errors (7d)') ?></td><td class="is-num"><span class="acp-pill <?= $stats['plugin_errors_7d'] > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$stats['plugin_errors_7d'] ?></span></td></tr> | |
| 311 | + | </tbody> | |
| 312 | + | </table> | |
| 313 | + | </div> | |
| 314 | + | </section> | |
| 315 | + | </div> |
| @@ -0,0 +1,104 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: API | |
| 4 | + | * Icon: fa-code | |
| 5 | + | * Group: Operations | |
| 6 | + | * Order: 60 | |
| 7 | + | * Description: Every public JSON endpoint under /api/, discovered automatically. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function acp_api_known_endpoints(): array { | |
| 16 | + | return array( | |
| 17 | + | 'index.php' => t_default('acp.api.desc_index', 'Site title, account/player counts, players online (with unique IPs), client version, port.'), | |
| 18 | + | 'modules/status/online.php' => t_default('acp.api.desc_online', 'The currently online players: name, level, vocation.'), | |
| 19 | + | 'modules/highscores/topExperience.php' => t_default('acp.api.desc_top_experience', 'Top players by experience. ?rows=N (default 10, max 100).'), | |
| 20 | + | 'modules/towns/getTownNames.php' => t_default('acp.api.desc_town_names', 'Configured town id => name map.'), | |
| 21 | + | 'modules/character/info.php' => t_default('acp.api.desc_character_info', 'Character card by name (?name=). Respects hidden characters.'), | |
| 22 | + | 'modules/highscores/top.php' => t_default('acp.api.desc_highscores_top', 'Highscores by skill type, same data as the site page. ?type=1-9&vocation=id|all&rows=N.'), | |
| 23 | + | ); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | $root = dirname(__DIR__, 2) . '/api'; | |
| 27 | + | $files = array(); | |
| 28 | + | ||
| 29 | + | if (is_dir($root)) { | |
| 30 | + | $files[] = 'index.php'; | |
| 31 | + | ||
| 32 | + | $modulesDir = $root . '/modules'; | |
| 33 | + | if (is_dir($modulesDir)) { | |
| 34 | + | $iterator = new RecursiveIteratorIterator( | |
| 35 | + | new RecursiveDirectoryIterator($modulesDir, FilesystemIterator::SKIP_DOTS) | |
| 36 | + | ); | |
| 37 | + | foreach ($iterator as $file) { | |
| 38 | + | if (!$file->isFile() || $file->getExtension() !== 'php') { | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); | |
| 42 | + | if (strpos($relative, '/class/') !== false) { | |
| 43 | + | continue; | |
| 44 | + | } | |
| 45 | + | $files[] = $relative; | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | sort($files); | |
| 51 | + | ||
| 52 | + | $known = acp_api_known_endpoints(); | |
| 53 | + | $siteUrl = rtrim((string)($config['site_url'] ?? ''), '/'); | |
| 54 | + | ?> | |
| 55 | + | ||
| 56 | + | <section class="acp-card"> | |
| 57 | + | <header class="acp-card-head"> | |
| 58 | + | <h2><?= t_default('acp.api.title', 'Public JSON endpoints') ?></h2> | |
| 59 | + | <p><?= t_default('acp.api.sub', 'Everything under /api/ - read-only, no authentication. Point a Discord bot or external tool at these.') ?></p> | |
| 60 | + | </header> | |
| 61 | + | <div class="acp-card-body is-flush"> | |
| 62 | + | <?php if ($files): ?> | |
| 63 | + | <div class="acp-table-wrap"> | |
| 64 | + | <table class="acp-table" data-sortable> | |
| 65 | + | <thead> | |
| 66 | + | <tr> | |
| 67 | + | <th><?= t_default('acp.api.col_endpoint', 'Endpoint') ?></th> | |
| 68 | + | <th><?= t_default('acp.api.col_description', 'What it returns') ?></th> | |
| 69 | + | <th></th> | |
| 70 | + | </tr> | |
| 71 | + | </thead> | |
| 72 | + | <tbody> | |
| 73 | + | <?php foreach ($files as $file): $url = $siteUrl . '/api/' . $file; ?> | |
| 74 | + | <tr> | |
| 75 | + | <td><code>/api/<?= h($file) ?></code></td> | |
| 76 | + | <td><?= h($known[$file] ?? '') ?></td> | |
| 77 | + | <td class="is-num"> | |
| 78 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h($url) ?>" target="_blank" rel="noopener"> | |
| 79 | + | <i class="fa fa-external-link"></i> <?= t_default('acp.api.open_btn', 'Open') ?> | |
| 80 | + | </a> | |
| 81 | + | </td> | |
| 82 | + | </tr> | |
| 83 | + | <?php endforeach; ?> | |
| 84 | + | </tbody> | |
| 85 | + | </table> | |
| 86 | + | </div> | |
| 87 | + | <?php else: ?> | |
| 88 | + | <?php acp_empty(t_default('acp.api.empty', 'No API endpoints found.'), 'fa-code'); ?> | |
| 89 | + | <?php endif; ?> | |
| 90 | + | </div> | |
| 91 | + | </section> | |
| 92 | + | ||
| 93 | + | <section class="acp-card"> | |
| 94 | + | <header class="acp-card-head"> | |
| 95 | + | <h2><?= t_default('acp.api.response_title', 'Response shape') ?></h2> | |
| 96 | + | </header> | |
| 97 | + | <div class="acp-card-body"> | |
| 98 | + | <p><?= t_default('acp.api.response_text', 'Every endpoint replies with JSON shaped like this - "version" always present, "data" holding the actual payload:') ?></p> | |
| 99 | + | <pre style="background:var(--acp-panel-2);padding:10px;border-radius:var(--acp-radius);overflow-x:auto;">{ | |
| 100 | + | "version": { "znote": "2.0.1", "ot": "TFS_10", "module": 1 }, | |
| 101 | + | "data": { ... } | |
| 102 | + | }</pre> | |
| 103 | + | </div> | |
| 104 | + | </section> |
| @@ -0,0 +1,268 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Character Auctions | |
| 4 | + | * Icon: fa-gavel | |
| 5 | + | * Group: Economy | |
| 6 | + | * Order: 20 | |
| 7 | + | * Description: Ongoing, unclaimed and completed character sales. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | $auction = $config['shop_auction'] ?? []; | |
| 16 | + | $storageAccountId = (int)($auction['storage_account_id'] ?? 0); | |
| 17 | + | $now = time(); | |
| 18 | + | ||
| 19 | + | function acp_duration(int $seconds): string { | |
| 20 | + | if ($seconds <= 0) { | |
| 21 | + | return t('acp.auc.zero_seconds'); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | $units = [ | |
| 25 | + | 'day' => [86400, 'acp.auc.day', 'acp.auc.days'], | |
| 26 | + | 'hour' => [3600, 'acp.auc.hour', 'acp.auc.hours'], | |
| 27 | + | 'minute' => [60, 'acp.auc.minute', 'acp.auc.minutes'], | |
| 28 | + | 'second' => [1, 'acp.auc.second', 'acp.auc.seconds'], | |
| 29 | + | ]; | |
| 30 | + | ||
| 31 | + | $parts = []; | |
| 32 | + | foreach ($units as [$value, $oneKey, $manyKey]) { | |
| 33 | + | $qty = intdiv($seconds, $value); | |
| 34 | + | if ($qty > 0) { | |
| 35 | + | $seconds -= $qty * $value; | |
| 36 | + | $parts[] = $qty . ' ' . t($qty === 1 ? $oneKey : $manyKey); | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return implode(', ', $parts); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // --------------------------------------------------------------------------- | |
| 44 | + | // Passive sweep: a bid period that ran out with a bidder on it is a sale. | |
| 45 | + | // --------------------------------------------------------------------------- | |
| 46 | + | $expired = db()->fetchAll(" | |
| 47 | + | SELECT `id` | |
| 48 | + | FROM `znote_auction_player` | |
| 49 | + | WHERE `sold` = 0 | |
| 50 | + | AND `time_end` < ? | |
| 51 | + | AND `bidder_account_id` > 0; | |
| 52 | + | ", [$now]); | |
| 53 | + | ||
| 54 | + | if (is_array($expired) && $expired) { | |
| 55 | + | $soldIds = array_map(static fn($a) => (int)$a['id'], $expired); | |
| 56 | + | $placeholders = implode(',', array_fill(0, count($soldIds), '?')); | |
| 57 | + | db()->execute(" | |
| 58 | + | UPDATE `znote_auction_player` | |
| 59 | + | SET `sold` = 1 | |
| 60 | + | WHERE `id` IN ({$placeholders}); | |
| 61 | + | ", $soldIds); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // --------------------------------------------------------------------------- | |
| 65 | + | // The three lists | |
| 66 | + | // --------------------------------------------------------------------------- | |
| 67 | + | $characterFields = " | |
| 68 | + | `za`.`id` AS `zaid`, | |
| 69 | + | `za`.`price`, | |
| 70 | + | `za`.`bid`, | |
| 71 | + | `za`.`time_begin`, | |
| 72 | + | `za`.`time_end`, | |
| 73 | + | `p`.`id` AS `player_id`, | |
| 74 | + | `p`.`name`, | |
| 75 | + | `p`.`vocation`, | |
| 76 | + | `p`.`level` | |
| 77 | + | "; | |
| 78 | + | ||
| 79 | + | $pending = db()->fetchAll(" | |
| 80 | + | SELECT {$characterFields} | |
| 81 | + | FROM `znote_auction_player` `za` | |
| 82 | + | INNER JOIN `players` `p` ON `za`.`player_id` = `p`.`id` | |
| 83 | + | WHERE `p`.`account_id` = ? | |
| 84 | + | AND `za`.`claimed` = 0 | |
| 85 | + | AND `za`.`sold` = 1 | |
| 86 | + | ORDER BY `za`.`time_end` DESC; | |
| 87 | + | ", [$storageAccountId]); | |
| 88 | + | ||
| 89 | + | $ongoing = db()->fetchAll(" | |
| 90 | + | SELECT {$characterFields} | |
| 91 | + | FROM `znote_auction_player` `za` | |
| 92 | + | INNER JOIN `players` `p` ON `za`.`player_id` = `p`.`id` | |
| 93 | + | WHERE `p`.`account_id` = ? | |
| 94 | + | AND `za`.`sold` = 0 | |
| 95 | + | ORDER BY `za`.`time_end` DESC; | |
| 96 | + | ", [$storageAccountId]); | |
| 97 | + | ||
| 98 | + | $completed = db()->fetchAll(" | |
| 99 | + | SELECT {$characterFields} | |
| 100 | + | FROM `znote_auction_player` `za` | |
| 101 | + | INNER JOIN `players` `p` ON `za`.`player_id` = `p`.`id` | |
| 102 | + | WHERE `za`.`claimed` = 1 | |
| 103 | + | ORDER BY `za`.`time_end` DESC; | |
| 104 | + | "); | |
| 105 | + | ||
| 106 | + | $pending = is_array($pending) ? $pending : []; | |
| 107 | + | $ongoing = is_array($ongoing) ? $ongoing : []; | |
| 108 | + | $completed = is_array($completed) ? $completed : []; | |
| 109 | + | ?> | |
| 110 | + | ||
| 111 | + | <div class="acp-stats"> | |
| 112 | + | <?php | |
| 113 | + | acp_stat(t('acp.auc.stat_ongoing'), count($ongoing), 'fa-gavel', null, 'blue'); | |
| 114 | + | acp_stat(t('acp.auc.stat_pending'), count($pending), 'fa-hourglass-half', null, 'amber'); | |
| 115 | + | acp_stat(t('acp.auc.stat_completed'), count($completed), 'fa-check-circle', null, 'green'); | |
| 116 | + | ?> | |
| 117 | + | </div> | |
| 118 | + | ||
| 119 | + | <?php if ($storageAccountId <= 0): ?> | |
| 120 | + | <div class="acp-flash acp-flash--error"> | |
| 121 | + | <i class="fa fa-exclamation-triangle"></i> | |
| 122 | + | <span> | |
| 123 | + | <?= t('acp.auc.no_storage', ['code' => '<code>$config[\'shop_auction\'][\'storage_account_id\']</code>']) ?> | |
| 124 | + | </span> | |
| 125 | + | </div> | |
| 126 | + | <?php endif; ?> | |
| 127 | + | ||
| 128 | + | <!-- ------------------------------------------------------------- Ongoing --> | |
| 129 | + | <section class="acp-card"> | |
| 130 | + | <header class="acp-card-head"> | |
| 131 | + | <h2><?= t('acp.auc.ongoing_title') ?></h2> | |
| 132 | + | <p><?= t('acp.auc.ongoing_sub') ?></p> | |
| 133 | + | </header> | |
| 134 | + | <div class="acp-card-body is-flush"> | |
| 135 | + | <?php if ($ongoing): ?> | |
| 136 | + | <div class="acp-table-wrap"> | |
| 137 | + | <table class="acp-table"> | |
| 138 | + | <thead> | |
| 139 | + | <tr> | |
| 140 | + | <th class="is-num"><?= t('acp.auc.col_level') ?></th> | |
| 141 | + | <th><?= t('acp.auc.col_vocation') ?></th> | |
| 142 | + | <th class="is-num"><?= t('acp.auc.col_price') ?></th> | |
| 143 | + | <th class="is-num"><?= t('acp.auc.col_bid') ?></th> | |
| 144 | + | <th><?= t('acp.auc.col_listed') ?></th> | |
| 145 | + | <th><?= t('acp.auc.col_type') ?></th> | |
| 146 | + | <th class="is-num"> </th> | |
| 147 | + | </tr> | |
| 148 | + | </thead> | |
| 149 | + | <tbody> | |
| 150 | + | <?php foreach ($ongoing as $c): | |
| 151 | + | $ended = $now > (int)$c['time_end']; | |
| 152 | + | ?> | |
| 153 | + | <tr> | |
| 154 | + | <td class="is-num"><?= (int)$c['level'] ?></td> | |
| 155 | + | <td><?= h(vocation_id_to_name((int)$c['vocation'])) ?></td> | |
| 156 | + | <td class="is-num"><?= (int)$c['price'] ?></td> | |
| 157 | + | <td class="is-num"><?= (int)$c['bid'] ?></td> | |
| 158 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$c['time_begin'], true)) ?></td> | |
| 159 | + | <td> | |
| 160 | + | <?php if ($ended): ?> | |
| 161 | + | <span class="acp-pill acp-pill--blue"><?= t('acp.auc.instant_buy') ?></span> | |
| 162 | + | <?php else: ?> | |
| 163 | + | <span class="acp-pill acp-pill--amber"><?= t('acp.auc.bidding') ?></span> | |
| 164 | + | <span class="is-muted"><?= t('acp.auc.left', ['time' => acp_duration((int)$c['time_end'] - $now)]) ?></span> | |
| 165 | + | <?php endif; ?> | |
| 166 | + | </td> | |
| 167 | + | <td class="is-num is-nowrap"> | |
| 168 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" | |
| 169 | + | href="<?= h(acp_site('auctionChar.php?action=view&zaid=' . (int)$c['zaid'])) ?>" | |
| 170 | + | target="_blank" rel="noopener"> | |
| 171 | + | <i class="fa fa-eye"></i> <?= t('acp.auc.view') ?> | |
| 172 | + | </a> | |
| 173 | + | </td> | |
| 174 | + | </tr> | |
| 175 | + | <?php endforeach; ?> | |
| 176 | + | </tbody> | |
| 177 | + | </table> | |
| 178 | + | </div> | |
| 179 | + | <?php else: ?> | |
| 180 | + | <?php acp_empty(t('acp.auc.none_ongoing'), 'fa-gavel'); ?> | |
| 181 | + | <?php endif; ?> | |
| 182 | + | </div> | |
| 183 | + | </section> | |
| 184 | + | ||
| 185 | + | <?php | |
| 186 | + | /** | |
| 187 | + | * Sold/completed lists share the same shape. | |
| 188 | + | * | |
| 189 | + | * @param array<int, array<string, mixed>> $rows | |
| 190 | + | */ | |
| 191 | + | function acp_auction_table(array $rows, string $title, string $subtitle, string $emptyText): void { | |
| 192 | + | ?> | |
| 193 | + | <section class="acp-card"> | |
| 194 | + | <header class="acp-card-head"> | |
| 195 | + | <h2><?= h($title) ?></h2> | |
| 196 | + | <p><?= h($subtitle) ?></p> | |
| 197 | + | </header> | |
| 198 | + | <div class="acp-card-body is-flush"> | |
| 199 | + | <?php if (!$rows): ?> | |
| 200 | + | <?php acp_empty($emptyText, 'fa-archive'); ?> | |
| 201 | + | <?php else: ?> | |
| 202 | + | <div class="acp-table-wrap"> | |
| 203 | + | <table class="acp-table"> | |
| 204 | + | <thead> | |
| 205 | + | <tr> | |
| 206 | + | <th><?= t('acp.auc.col_character') ?></th> | |
| 207 | + | <th class="is-num"><?= t('acp.auc.col_level') ?></th> | |
| 208 | + | <th><?= t('acp.auc.col_vocation') ?></th> | |
| 209 | + | <th class="is-num"><?= t('acp.auc.col_price') ?></th> | |
| 210 | + | <th class="is-num"><?= t('acp.auc.col_bid') ?></th> | |
| 211 | + | <th><?= t('acp.auc.col_listed') ?></th> | |
| 212 | + | <th><?= t('acp.auc.col_ended') ?></th> | |
| 213 | + | </tr> | |
| 214 | + | </thead> | |
| 215 | + | <tbody> | |
| 216 | + | <?php foreach ($rows as $c): ?> | |
| 217 | + | <tr> | |
| 218 | + | <td class="is-nowrap"> | |
| 219 | + | <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$c['name']))) ?>" target="_blank" rel="noopener"> | |
| 220 | + | <?= h((string)$c['name']) ?> | |
| 221 | + | </a> | |
| 222 | + | </td> | |
| 223 | + | <td class="is-num"><?= (int)$c['level'] ?></td> | |
| 224 | + | <td><?= h(vocation_id_to_name((int)$c['vocation'])) ?></td> | |
| 225 | + | <td class="is-num"><?= (int)$c['price'] ?></td> | |
| 226 | + | <td class="is-num"><strong><?= (int)$c['bid'] ?></strong></td> | |
| 227 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$c['time_begin'], true)) ?></td> | |
| 228 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$c['time_end'], true)) ?></td> | |
| 229 | + | </tr> | |
| 230 | + | <?php endforeach; ?> | |
| 231 | + | </tbody> | |
| 232 | + | </table> | |
| 233 | + | </div> | |
| 234 | + | <?php endif; ?> | |
| 235 | + | </div> | |
| 236 | + | </section> | |
| 237 | + | <?php | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | acp_auction_table( | |
| 241 | + | $pending, | |
| 242 | + | t('acp.auc.pending_title'), | |
| 243 | + | t('acp.auc.pending_sub'), | |
| 244 | + | t('acp.auc.pending_empty') | |
| 245 | + | ); | |
| 246 | + | ||
| 247 | + | acp_auction_table( | |
| 248 | + | $completed, | |
| 249 | + | t('acp.auc.completed_title'), | |
| 250 | + | t('acp.auc.completed_sub'), | |
| 251 | + | t('acp.auc.completed_empty') | |
| 252 | + | ); | |
| 253 | + | ?> | |
| 254 | + | ||
| 255 | + | <section class="acp-card"> | |
| 256 | + | <details> | |
| 257 | + | <summary class="acp-card-head" style="cursor:pointer;"> | |
| 258 | + | <h2><?= t('acp.auc.how_title') ?></h2> | |
| 259 | + | <p><?= t('acp.auc.how_sub', ['code' => '<code>$config[\'shop_auction\']</code>']) ?></p> | |
| 260 | + | </summary> | |
| 261 | + | <div class="acp-card-body"> | |
| 262 | + | <p> | |
| 263 | + | <?= t('acp.auc.how_text') ?> | |
| 264 | + | </p> | |
| 265 | + | <?php data_dump($auction, false, "config.php: shop_auction"); ?> | |
| 266 | + | </div> | |
| 267 | + | </details> | |
| 268 | + | </section> |
| @@ -0,0 +1,152 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Backups | |
| 4 | + | * Icon: fa-archive | |
| 5 | + | * Group: Operations | |
| 6 | + | * Order: 20 | |
| 7 | + | * Description: Create, download and manage database backups. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | $retentionKey = 'config:backups.retention'; | |
| 16 | + | $retention = max(1, (int)setting($retentionKey, '10')); | |
| 17 | + | ||
| 18 | + | // --------------------------------------------------------------------------- | |
| 19 | + | // Download - streams the file directly, bypassing the normal page render. | |
| 20 | + | // --------------------------------------------------------------------------- | |
| 21 | + | if (($_GET['do'] ?? '') === 'download') { | |
| 22 | + | $path = znote_backups_path((string)($_GET['name'] ?? '')); | |
| 23 | + | if ($path === null) { | |
| 24 | + | http_response_code(404); | |
| 25 | + | die('No such backup.'); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | acp_log('backups.download', basename($path)); | |
| 29 | + | ||
| 30 | + | while (function_exists('ob_get_level') && ob_get_level() > 0) { | |
| 31 | + | ob_end_clean(); | |
| 32 | + | } | |
| 33 | + | header('Content-Type: application/gzip'); | |
| 34 | + | header('Content-Disposition: attachment; filename="' . basename($path) . '"'); | |
| 35 | + | header('Content-Length: ' . filesize($path)); | |
| 36 | + | readfile($path); | |
| 37 | + | exit; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 41 | + | $do = (string)($_POST['do'] ?? ''); | |
| 42 | + | ||
| 43 | + | if ($do === 'create') { | |
| 44 | + | list($ok, $result) = znote_backups_create(); | |
| 45 | + | if ($ok) { | |
| 46 | + | $removed = znote_backups_prune($retention); | |
| 47 | + | acp_log('backups.create', $result, ['pruned' => $removed]); | |
| 48 | + | acp_flash_success(t_default('acp.bkp.created', 'Backup created: {name}.', ['name' => h($result)])); | |
| 49 | + | } else { | |
| 50 | + | acp_flash_error(t_default('acp.bkp.create_failed', 'Backup failed: {error}', ['error' => h($result)])); | |
| 51 | + | } | |
| 52 | + | acp_redirect('backups'); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if ($do === 'delete') { | |
| 56 | + | $name = (string)($_POST['name'] ?? ''); | |
| 57 | + | if (znote_backups_delete($name)) { | |
| 58 | + | acp_log('backups.delete', $name); | |
| 59 | + | acp_flash_success(t_default('acp.bkp.deleted', 'Backup deleted.')); | |
| 60 | + | } else { | |
| 61 | + | acp_flash_error(t_default('acp.bkp.delete_failed', 'Could not delete that backup.')); | |
| 62 | + | } | |
| 63 | + | acp_redirect('backups'); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | if ($do === 'retention') { | |
| 67 | + | $value = max(1, min(100, intv($_POST['retention'] ?? 10))); | |
| 68 | + | setting_set($retentionKey, (string)$value); | |
| 69 | + | acp_log('backups.retention', (string)$value); | |
| 70 | + | acp_flash_success(t_default('acp.bkp.retention_saved', 'Kept backups set to {n}.', ['n' => $value])); | |
| 71 | + | acp_redirect('backups'); | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | $backups = znote_backups_list(); | |
| 76 | + | $totalSize = array_sum(array_column($backups, 'size')); | |
| 77 | + | ?> | |
| 78 | + | ||
| 79 | + | <div class="acp-stats"> | |
| 80 | + | <?php | |
| 81 | + | acp_stat(t_default('acp.bkp.stat_count', 'Backups stored'), count($backups), 'fa-life-ring', null, 'blue'); | |
| 82 | + | acp_stat(t_default('acp.bkp.stat_size', 'Total size'), serverdata_human_size($totalSize), 'fa-hdd-o', null, 'purple'); | |
| 83 | + | ?> | |
| 84 | + | </div> | |
| 85 | + | ||
| 86 | + | <section class="acp-card"> | |
| 87 | + | <header class="acp-card-head"> | |
| 88 | + | <h2><?= t_default('acp.bkp.create_title', 'Create a backup') ?></h2> | |
| 89 | + | <p><?= t_default('acp.bkp.create_sub', 'A full SQL dump of the database, compressed. Restorable with any MySQL client - nothing ZnoteX-specific about the file.') ?></p> | |
| 90 | + | </header> | |
| 91 | + | <div class="acp-card-body"> | |
| 92 | + | <form method="post" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> | |
| 93 | + | <?= acp_csrf_field() ?> | |
| 94 | + | <input type="hidden" name="do" value="create"> | |
| 95 | + | <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-plus"></i> <?= t_default('acp.bkp.create_btn', 'Create backup now') ?></button> | |
| 96 | + | <span class="acp-hint"><?= t_default('acp.bkp.create_hint', 'A large database can take a while - the page waits for it to finish.') ?></span> | |
| 97 | + | </form> | |
| 98 | + | ||
| 99 | + | <form method="post" style="display:flex;align-items:center;gap:10px;margin-top:16px;padding-top:16px;border-top:1px solid var(--acp-line);"> | |
| 100 | + | <?= acp_csrf_field() ?> | |
| 101 | + | <input type="hidden" name="do" value="retention"> | |
| 102 | + | <label class="acp-label" for="bkp_retention" style="margin:0;"><?= t_default('acp.bkp.retention_label', 'Keep the last') ?></label> | |
| 103 | + | <input class="acp-input" id="bkp_retention" name="retention" type="number" min="1" max="100" value="<?= $retention ?>" style="width:80px;"> | |
| 104 | + | <span class="is-muted"><?= t_default('acp.bkp.retention_suffix', 'backups (older ones are deleted right after a new one is made)') ?></span> | |
| 105 | + | <button class="acp-btn acp-btn--sm" type="submit"><i class="fa fa-check"></i> <?= t_default('acp.bkp.retention_save_btn', 'Save') ?></button> | |
| 106 | + | </form> | |
| 107 | + | </div> | |
| 108 | + | </section> | |
| 109 | + | ||
| 110 | + | <section class="acp-card"> | |
| 111 | + | <header class="acp-card-head"> | |
| 112 | + | <h2><?= t_default('acp.bkp.list_title', 'Existing backups') ?></h2> | |
| 113 | + | </header> | |
| 114 | + | <div class="acp-card-body is-flush"> | |
| 115 | + | <?php if ($backups): ?> | |
| 116 | + | <div class="acp-table-wrap"> | |
| 117 | + | <table class="acp-table" data-sortable> | |
| 118 | + | <thead> | |
| 119 | + | <tr> | |
| 120 | + | <th><?= t_default('acp.bkp.col_name', 'File') ?></th> | |
| 121 | + | <th><?= t_default('acp.bkp.col_date', 'Created') ?></th> | |
| 122 | + | <th class="is-num"><?= t_default('acp.bkp.col_size', 'Size') ?></th> | |
| 123 | + | <th class="is-num"><?= t_default('acp.bkp.col_actions', 'Actions') ?></th> | |
| 124 | + | </tr> | |
| 125 | + | </thead> | |
| 126 | + | <tbody> | |
| 127 | + | <?php foreach ($backups as $backup): ?> | |
| 128 | + | <tr> | |
| 129 | + | <td><code><?= h($backup['name']) ?></code></td> | |
| 130 | + | <td class="is-nowrap is-muted"><?= h(getClock($backup['time'], true)) ?></td> | |
| 131 | + | <td class="is-num"><?= h(number_format($backup['size'] / 1048576, 2)) ?> MB</td> | |
| 132 | + | <td class="is-nowrap is-num"> | |
| 133 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('backups', ['do' => 'download', 'name' => $backup['name']])) ?>"> | |
| 134 | + | <i class="fa fa-download"></i> | |
| 135 | + | </a> | |
| 136 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.bkp.confirm_delete', 'Delete this backup? This cannot be undone.')) ?>"> | |
| 137 | + | <?= acp_csrf_field() ?> | |
| 138 | + | <input type="hidden" name="do" value="delete"> | |
| 139 | + | <input type="hidden" name="name" value="<?= h($backup['name']) ?>"> | |
| 140 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i></button> | |
| 141 | + | </form> | |
| 142 | + | </td> | |
| 143 | + | </tr> | |
| 144 | + | <?php endforeach; ?> | |
| 145 | + | </tbody> | |
| 146 | + | </table> | |
| 147 | + | </div> | |
| 148 | + | <?php else: ?> | |
| 149 | + | <?php acp_empty(t_default('acp.bkp.empty', 'No backups yet.'), 'fa-life-ring'); ?> | |
| 150 | + | <?php endif; ?> | |
| 151 | + | </div> | |
| 152 | + | </section> |
| @@ -0,0 +1,256 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Changelog | |
| 4 | + | * Icon: fa-list-ul | |
| 5 | + | * Group: Content | |
| 6 | + | * Order: 20 | |
| 7 | + | * Description: Write and manage the entries shown on the public changelog page. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | if (!defined('ACP_ROOT')) { | |
| 11 | + | http_response_code(403); | |
| 12 | + | die('Direct access denied.'); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * Rebuild the cache the public page reads. | |
| 17 | + | * changelog.php serves from engine/cache/changelog and only refreshes it after | |
| 18 | + | * a write, so every change here has to refresh it too or the site shows stale | |
| 19 | + | * entries until something else happens to save it. | |
| 20 | + | */ | |
| 21 | + | function acp_changelog_rebuild_cache(): void { | |
| 22 | + | $cache = new Cache('engine/cache/changelog'); | |
| 23 | + | $cache->useMemory(false); | |
| 24 | + | $cache->setContent(db()->fetchAll(" | |
| 25 | + | SELECT `id`, `text`, `time`, `report_id`, `status` | |
| 26 | + | FROM `znote_changelog` | |
| 27 | + | ORDER BY `id` DESC; | |
| 28 | + | ") ?: []); | |
| 29 | + | $cache->save(); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // The public page writes 35 for entries created by hand; reports.php writes the | |
| 33 | + | // report status. Kept as-is so both keep meaning the same thing. | |
| 34 | + | const ACP_CHANGELOG_MANUAL_STATUS = 35; | |
| 35 | + | ||
| 36 | + | // --------------------------------------------------------------------------- | |
| 37 | + | // Mutations | |
| 38 | + | // --------------------------------------------------------------------------- | |
| 39 | + | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 40 | + | ||
| 41 | + | $do = (string)($_POST['do'] ?? ''); | |
| 42 | + | $id = intv($_POST['id'] ?? 0); | |
| 43 | + | ||
| 44 | + | if ($do === 'delete' && $id > 0) { | |
| 45 | + | db()->execute("DELETE FROM `znote_changelog` WHERE `id` = ? LIMIT 1;", [$id]); | |
| 46 | + | acp_changelog_rebuild_cache(); | |
| 47 | + | acp_log('changelog.delete', '#' . $id); | |
| 48 | + | acp_flash_success(t('acp.chg.deleted')); | |
| 49 | + | acp_redirect('changelog'); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | $text = trim((string)($_POST['text'] ?? '')); | |
| 53 | + | ||
| 54 | + | if ($text === '') { | |
| 55 | + | acp_flash_error(t('acp.chg.empty')); | |
| 56 | + | acp_redirect('changelog', $id > 0 ? ['action' => 'edit', 'id' => $id] : []); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // The column is varchar(255). Cutting is no longer safe now that the text can | |
| 60 | + | // carry BBCode - substr() would happily slice [b]word[/b] into [b]word[/ and | |
| 61 | + | // leave the tag dangling on the public page. Refuse instead and say why. | |
| 62 | + | if (strlen($text) > 254) { | |
| 63 | + | acp_flash_error(t('acp.chg.too_long', ['n' => strlen($text)])); | |
| 64 | + | acp_redirect('changelog', $id > 0 ? ['action' => 'edit', 'id' => $id] : []); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | if ($do === 'update' && $id > 0) { | |
| 68 | + | db()->execute(" | |
| 69 | + | UPDATE `znote_changelog` | |
| 70 | + | SET `text` = ? | |
| 71 | + | WHERE `id` = ? | |
| 72 | + | LIMIT 1; | |
| 73 | + | ", [$text, $id]); | |
| 74 | + | acp_changelog_rebuild_cache(); | |
| 75 | + | acp_log('changelog.update', '#' . $id, ['text' => substr($text, 0, 60)]); | |
| 76 | + | acp_flash_success(t('acp.chg.updated')); | |
| 77 | + | acp_redirect('changelog'); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | if ($do === 'create') { | |
| 81 | + | $when = intv($_POST['time'] ?? 0); | |
| 82 | + | if ($when <= 0) { | |
| 83 | + | $when = time(); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | db()->execute(" | |
| 87 | + | INSERT INTO `znote_changelog` (`text`, `time`, `report_id`, `status`) | |
| 88 | + | VALUES (?, ?, 0, ?); | |
| 89 | + | ", [$text, $when, ACP_CHANGELOG_MANUAL_STATUS]); | |
| 90 | + | acp_changelog_rebuild_cache(); | |
| 91 | + | acp_log('changelog.create', '', ['text' => substr($text, 0, 60)]); | |
| 92 | + | acp_flash_success(t('acp.chg.published')); | |
| 93 | + | acp_redirect('changelog'); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | acp_flash_error(t('acp.chg.unknown_action')); | |
| 97 | + | acp_redirect('changelog'); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // --------------------------------------------------------------------------- | |
| 101 | + | // View state | |
| 102 | + | // --------------------------------------------------------------------------- | |
| 103 | + | $entries = db()->fetchAll(" | |
| 104 | + | SELECT `id`, `text`, `time`, `report_id`, `status` | |
| 105 | + | FROM `znote_changelog` | |
| 106 | + | ORDER BY `id` DESC; | |
| 107 | + | "); | |
| 108 | + | $entries = is_array($entries) ? $entries : []; | |
| 109 | + | ||
| 110 | + | $editing = null; | |
| 111 | + | if (($_GET['action'] ?? '') === 'edit') { | |
| 112 | + | $editId = intv($_GET['id'] ?? 0); | |
| 113 | + | foreach ($entries as $entry) { | |
| 114 | + | if ((int)$entry['id'] === $editId) { | |
| 115 | + | $editing = $entry; | |
| 116 | + | break; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if ($editing === null) { | |
| 120 | + | acp_flash_error(t('acp.chg.not_found')); | |
| 121 | + | acp_redirect('changelog'); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | $fromReports = 0; | |
| 126 | + | foreach ($entries as $entry) { | |
| 127 | + | if ((int)$entry['report_id'] > 0) { | |
| 128 | + | $fromReports++; | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ?> | |
| 132 | + | ||
| 133 | + | <div class="acp-stats"> | |
| 134 | + | <?php | |
| 135 | + | acp_stat(t('acp.chg.stat_entries'), count($entries), 'fa-list-ul', null, 'blue'); | |
| 136 | + | acp_stat(t('acp.chg.stat_from_reports'), $fromReports, 'fa-bug', acp_url('reports'), 'purple'); | |
| 137 | + | ?> | |
| 138 | + | </div> | |
| 139 | + | ||
| 140 | + | <div class="acp-grid acp-grid--2"> | |
| 141 | + | ||
| 142 | + | <section class="acp-card"> | |
| 143 | + | <header class="acp-card-head"> | |
| 144 | + | <h2><?= $editing !== null ? t('acp.chg.edit_entry', ['id' => (int)$editing['id']]) : t('acp.chg.new_entry') ?></h2> | |
| 145 | + | <p><?= t('acp.chg.appears_top') ?></p> | |
| 146 | + | </header> | |
| 147 | + | <div class="acp-card-body"> | |
| 148 | + | <form method="post"> | |
| 149 | + | <?= acp_csrf_field() ?> | |
| 150 | + | <input type="hidden" name="do" value="<?= $editing !== null ? 'update' : 'create' ?>"> | |
| 151 | + | <?php if ($editing !== null): ?> | |
| 152 | + | <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>"> | |
| 153 | + | <?php endif; ?> | |
| 154 | + | ||
| 155 | + | <div class="acp-field"> | |
| 156 | + | <label class="acp-label" for="text"><?= t('acp.chg.text_label') ?></label> | |
| 157 | + | <?php acp_editor('text', $editing !== null ? (string)$editing['text'] : '', [ | |
| 158 | + | 'height' => 150, | |
| 159 | + | 'maxlength' => 254, | |
| 160 | + | // A changelog line is one sentence in a varchar(255), so the | |
| 161 | + | // toolbar stays small - lists and images would not fit. | |
| 162 | + | 'toolbar' => 'bold,italic,underline,strike|color,removeformat|link,unlink|undo,redo,source', | |
| 163 | + | ]); ?> | |
| 164 | + | </div> | |
| 165 | + | ||
| 166 | + | <div class="acp-actions"> | |
| 167 | + | <button class="acp-btn acp-btn--green" type="submit"> | |
| 168 | + | <i class="fa fa-check"></i> <?= $editing !== null ? t('acp.chg.save_changes') : t('acp.chg.publish_entry') ?> | |
| 169 | + | </button> | |
| 170 | + | <?php if ($editing !== null): ?> | |
| 171 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('changelog')) ?>"><?= t('acp.chg.cancel') ?></a> | |
| 172 | + | <?php endif; ?> | |
| 173 | + | </div> | |
| 174 | + | </form> | |
| 175 | + | </div> | |
| 176 | + | </section> | |
| 177 | + | ||
| 178 | + | <section class="acp-card"> | |
| 179 | + | <header class="acp-card-head"> | |
| 180 | + | <h2><?= t('acp.chg.how_title') ?></h2> | |
| 181 | + | </header> | |
| 182 | + | <div class="acp-card-body"> | |
| 183 | + | <p> | |
| 184 | + | <?= t('acp.chg.how_text1', [ | |
| 185 | + | 'link' => '<a href="' . h(acp_url('reports')) . '">' . t('acp.chg.reports_link') . '</a>', | |
| 186 | + | ]) ?> | |
| 187 | + | </p> | |
| 188 | + | <p> | |
| 189 | + | <?= t('acp.chg.how_text2') ?> | |
| 190 | + | </p> | |
| 191 | + | <p> | |
| 192 | + | <a href="<?= h(acp_site('changelog.php')) ?>" target="_blank" rel="noopener"> | |
| 193 | + | <i class="fa fa-external-link"></i> <?= t('acp.chg.view_public') ?> | |
| 194 | + | </a> | |
| 195 | + | </p> | |
| 196 | + | </div> | |
| 197 | + | </section> | |
| 198 | + | </div> | |
| 199 | + | ||
| 200 | + | <section class="acp-card"> | |
| 201 | + | <header class="acp-card-head"> | |
| 202 | + | <h2><?= t('acp.chg.published_entries') ?></h2> | |
| 203 | + | <p><?= t('acp.chg.newest_first') ?></p> | |
| 204 | + | </header> | |
| 205 | + | <div class="acp-card-body is-flush"> | |
| 206 | + | <?php if ($entries): ?> | |
| 207 | + | <div class="acp-table-wrap"> | |
| 208 | + | <table class="acp-table"> | |
| 209 | + | <thead> | |
| 210 | + | <tr> | |
| 211 | + | <th>#</th> | |
| 212 | + | <th><?= t('acp.chg.col_date') ?></th> | |
| 213 | + | <th><?= t('acp.chg.col_entry') ?></th> | |
| 214 | + | <th><?= t('acp.chg.col_source') ?></th> | |
| 215 | + | <th class="is-num"><?= t('acp.chg.col_actions') ?></th> | |
| 216 | + | </tr> | |
| 217 | + | </thead> | |
| 218 | + | <tbody> | |
| 219 | + | <?php foreach ($entries as $entry): | |
| 220 | + | $id = (int)$entry['id']; | |
| 221 | + | $reportId = (int)$entry['report_id']; | |
| 222 | + | ?> | |
| 223 | + | <tr> | |
| 224 | + | <td class="is-muted"><?= $id ?></td> | |
| 225 | + | <td class="is-nowrap is-muted"><?= h(getClock((int)$entry['time'], true, true)) ?></td> | |
| 226 | + | <td><?= h((string)$entry['text']) ?></td> | |
| 227 | + | <td class="is-nowrap"> | |
| 228 | + | <?php if ($reportId > 0): ?> | |
| 229 | + | <a class="acp-pill acp-pill--purple" href="<?= h(acp_url('reports', ['action' => 'edit', 'id' => $reportId])) ?>"> | |
| 230 | + | <?= t('acp.chg.report_hash', ['id' => $reportId]) ?> | |
| 231 | + | </a> | |
| 232 | + | <?php else: ?> | |
| 233 | + | <span class="acp-pill acp-pill--grey"><?= t('acp.chg.manual') ?></span> | |
| 234 | + | <?php endif; ?> | |
| 235 | + | </td> | |
| 236 | + | <td class="is-num is-nowrap"> | |
| 237 | + | <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('changelog', ['action' => 'edit', 'id' => $id])) ?>"> | |
| 238 | + | <i class="fa fa-pencil"></i> <?= t('acp.chg.edit') ?> | |
| 239 | + | </a> | |
| 240 | + | <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.chg.confirm_delete')) ?>"> | |
| 241 | + | <?= acp_csrf_field() ?> | |
| 242 | + | <input type="hidden" name="do" value="delete"> | |
| 243 | + | <input type="hidden" name="id" value="<?= $id ?>"> | |
| 244 | + | <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i> <?= t('acp.chg.delete') ?></button> | |
| 245 | + | </form> | |
| 246 | + | </td> | |
| 247 | + | </tr> | |
| 248 | + | <?php endforeach; ?> | |
| 249 | + | </tbody> | |
| 250 | + | </table> | |
| 251 | + | </div> | |
| 252 | + | <?php else: ?> | |
| 253 | + | <?php acp_empty(t('acp.chg.no_entries'), 'fa-list-ul'); ?> | |
| 254 | + | <?php endif; ?> | |
| 255 | + | </div> | |
| 256 | + | </section> |
| @@ -0,0 +1,113 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Title: Config values | |
| 4 | + | * Icon: fa-wrench | |
| 5 | + | * Group: Server Info | |
| 6 | + | * Order: 11 | |
| 7 | + | * Description: Edit a single config.lua value without re-uploading the whole file. | |
| 8 | + | * Hidden: true | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | if (!defined('ACP_ROOT')) { | |
| 12 | + | http_response_code(403); | |
| 13 | + | die('Direct access denied.'); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | $whitelist = serverdata_config_whitelist(); | |
| 17 | + | $current = serverdata_load('config'); | |
| 18 | + | $current = is_array($current) ? $current : array(); | |
| 19 | + | ||
| 20 | + | if (!$current) { | |
| 21 | + | acp_flash_error(t_default('acp.cfged.no_data', 'Upload a config.lua 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 | + | $changed = array(); | |
| 28 | + | $failed = false; | |
| 29 | + | ||
| 30 | + | foreach ($whitelist as $key) { | |
| 31 | + | if (!array_key_exists($key, $current)) { | |
| 32 | + | continue; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | $existing = $current[$key]; | |
| 36 | + | $posted = $_POST['cfg'][$key] ?? null; | |
| 37 | + | ||
| 38 | + | if (is_bool($existing)) { | |
| 39 | + | $value = !empty($posted); | |
| 40 | + | } elseif (is_int($existing)) { | |
| 41 | + | $value = intv($posted ?? 0); | |
| 42 | + | } elseif (is_float($existing)) { | |
| 43 | + | $value = (float)str_replace(',', '.', (string)($posted ?? 0)); | |
| 44 | + | } else { | |
| 45 | + | $value = trim((string)($posted ?? '')); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if ($value !== $existing) { | |
| 49 | + | if (serverdata_override_set('config', $key, ['value' => $value], $adminName)) { | |
| 50 | + | $changed[] = $key; | |
| 51 | + | } else { | |
| 52 | + | $failed = true; | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | if ($changed) { | |
| 58 | + | acp_log('serverdata.config_edit', implode(', ', $changed), ['keys' => $changed]); | |
| 59 | + | acp_flash_success(t_default('acp.cfged.saved', '{n} value(s) updated.', ['n' => count($changed)])); | |
| 60 | + | } elseif (!$failed) { | |
| 61 | + | acp_flash_info(t_default('acp.cfged.nothing_changed', 'Nothing changed.')); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | if ($failed) { | |
| 65 | + | acp_flash_error(t_default('acp.cfged.save_failed', 'Some values could not be saved - run the pending database migration first (Admin Panel > Migrations).')); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | acp_redirect('config_editor'); | |
| 69 | + | } | |
| 70 | + | ?> | |
| 71 | + | ||
| 72 | + | <?php acp_card_open(t_default('acp.cfged.title', 'Config values'), t_default('acp.cfged.sub', 'These change what is shown on the public server info page. Your OT server\'s own config.lua is never touched.')); ?> | |
| 73 | + | ||
| 74 | + | <form method="post"> | |
| 75 | + | <?= acp_csrf_field() ?> | |
| 76 | + | <div class="acp-table-wrap"> | |
| 77 | + | <table class="acp-table"> | |
| 78 | + | <thead> | |
| 79 | + | <tr> | |
| 80 | + | <th><?= t_default('acp.cfged.col_key', 'Key') ?></th> | |
| 81 | + | <th><?= t_default('acp.cfged.col_value', 'Value') ?></th> | |
| 82 | + | </tr> | |
| 83 | + | </thead> | |
| 84 | + | <tbody> | |
| 85 | + | <?php foreach ($whitelist as $key): ?> | |
| 86 | + | <?php if (!array_key_exists($key, $current)) continue; ?> | |
| 87 | + | <?php $value = $current[$key]; ?> | |
| 88 | + | <tr> | |
| 89 | + | <td><code><?= h($key) ?></code></td> | |
| 90 | + | <td> | |
| 91 | + | <?php if (is_bool($value)): ?> | |
| 92 | + | <label style="display:flex;align-items:center;gap:8px;font-weight:400;"> | |
| 93 | + | <input type="checkbox" name="cfg[<?= h($key) ?>]" value="1" <?= $value ? 'checked' : '' ?>> | |
| 94 | + | <span class="is-muted"><?= t_default('acp.cfged.enabled', 'Enabled') ?></span> | |
| 95 | + | </label> | |
| 96 | + | <?php elseif (is_int($value) || is_float($value)): ?> | |
| 97 | + | <input class="acp-input" type="number" step="<?= is_float($value) ? 'any' : '1' ?>" name="cfg[<?= h($key) ?>]" value="<?= h((string)$value) ?>"> | |
| 98 | + | <?php else: ?> | |
| 99 | + | <input class="acp-input" type="text" name="cfg[<?= h($key) ?>]" value="<?= h((string)$value) ?>"> | |
| 100 | + | <?php endif; ?> | |
| 101 | + | </td> | |
| 102 | + | </tr> | |
| 103 | + | <?php endforeach; ?> | |
| 104 | + | </tbody> | |
| 105 | + | </table> | |
| 106 | + | </div> | |
| 107 | + | <div class="acp-actions"> | |
| 108 | + | <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= t_default('acp.cfged.save_btn', 'Save') ?></button> | |
| 109 | + | <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('serverinfo')) ?>"><?= t_default('acp.cfged.back', 'Back to Server Info') ?></a> | |
| 110 | + | </div> | |
| 111 | + | </form> | |
| 112 | + | ||
| 113 | + | <?php acp_card_close(); ?> |