Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A admin/modules/accounts.php +444-0 View file
@@ -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)) : '&mdash;' ?></dd>
202+ <dt><?= t('acp.acc.last_ip') ?></dt>
203+ <dd><?= !empty($account['ip']) ? '<code>' . h(long2ip((int)$account['ip'])) . '</code>' : '&mdash;' ?></dd>
204+ <dt><?= t('acp.acc.country') ?></dt>
205+ <dd><?= !empty($account['flag']) ? h((string)$account['flag']) : '&mdash;' ?></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">&nbsp;</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+ &middot; <?= 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">&nbsp;</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)) : '&mdash;' ?></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; ?>
A admin/modules/adminlog.php +237-0 View file
@@ -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+ &mdash;
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>
A admin/modules/analytics.php +315-0 View file
@@ -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>
A admin/modules/api_docs.php +104-0 View file
@@ -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>
A admin/modules/auction.php +268-0 View file
@@ -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">&nbsp;</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>
A admin/modules/backups.php +152-0 View file
@@ -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>
A admin/modules/changelog.php +256-0 View file
@@ -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>
A admin/modules/config_editor.php +113-0 View file
@@ -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(); ?>
Top