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/menus.php +452-0 View file
@@ -0,0 +1,452 @@
1+<?php
2+/**
3+ * Title: Menus
4+ * Icon: fa-bars
5+ * Group: Content
6+ * Order: 40
7+ * Description: Add, reorder and hide the links your theme shows.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_menu_visibility(): array {
16+ return array(
17+ 'all' => t('acp.menu.vis_all'),
18+ 'guest' => t('acp.menu.vis_guest'),
19+ 'user' => t('acp.menu.vis_user'),
20+ 'admin' => t('acp.menu.vis_admin'),
21+ );
22+}
23+
24+$locations = theme_menu_locations();
25+$location = (string)($_GET['loc'] ?? array_key_first($locations));
26+if (!isset($locations[$location])) {
27+ $location = (string)array_key_first($locations);
28+}
29+
30+$hasTable = znote_table_exists('znote_menu');
31+
32+// ---------------------------------------------------------------------------
33+// Mutations
34+// ---------------------------------------------------------------------------
35+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
36+
37+ $do = (string)($_POST['do'] ?? '');
38+ $id = intv($_POST['id'] ?? 0);
39+ $loc = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)($_POST['location'] ?? $location)));
40+ if (!isset($locations[$loc])) {
41+ $loc = $location;
42+ }
43+
44+ if ($do === 'delete' && $id > 0) {
45+ $entry = db()->fetchOne("SELECT `parent_id`, `label` FROM `znote_menu` WHERE `id` = ? LIMIT 1;", [$id]);
46+ if (is_array($entry) && (int)$entry['parent_id'] === 0) {
47+ db()->execute("DELETE FROM `znote_menu` WHERE `id` = ? OR `parent_id` = ?;", [$id, $id]);
48+ acp_log('menu.delete_category', (string)$entry['label']);
49+ acp_flash_success(t('acp.menu.category_deleted'));
50+ acp_redirect('menus', array('loc' => $loc));
51+ }
52+ db()->execute("DELETE FROM `znote_menu` WHERE `id` = ? LIMIT 1;", [$id]);
53+ acp_log('menu.delete', is_array($entry) ? (string)$entry['label'] : ('#' . $id));
54+ acp_flash_success(t('acp.menu.deleted'));
55+ acp_redirect('menus', array('loc' => $loc));
56+ }
57+
58+ if ($do === 'toggle' && $id > 0) {
59+ db()->execute("UPDATE `znote_menu` SET `active` = CASE WHEN `active` = 1 THEN 0 ELSE 1 END WHERE `id` = ? LIMIT 1;", [$id]);
60+ acp_redirect('menus', array('loc' => $loc));
61+ }
62+
63+ if ($do === 'move' && $id > 0) {
64+ $item = db()->fetchOne("
65+ SELECT `id`, `parent_id`, `location`
66+ FROM `znote_menu`
67+ WHERE `id` = ?
68+ LIMIT 1;
69+ ", [$id]);
70+ if (is_array($item)) {
71+ $siblings = db()->fetchAll("
72+ SELECT `id`
73+ FROM `znote_menu`
74+ WHERE `location` = ?
75+ AND `parent_id` = ?
76+ ORDER BY `sort_order` ASC, `id` ASC;
77+ ", [(string)$item['location'], (int)$item['parent_id']]);
78+ if (is_array($siblings)) {
79+ $ids = array_map('intval', array_column($siblings, 'id'));
80+ $pos = array_search($id, $ids, true);
81+ $swap = ((string)($_POST['dir'] ?? '') === 'up') ? ((int)$pos - 1) : ((int)$pos + 1);
82+
83+ if ($pos !== false && isset($ids[$swap])) {
84+ $ids[$pos] = $ids[$swap];
85+ $ids[$swap] = $id;
86+ foreach ($ids as $index => $rowId) {
87+ db()->execute("UPDATE `znote_menu` SET `sort_order` = ? WHERE `id` = ? LIMIT 1;", [($index + 1) * 10, $rowId]);
88+ }
89+ }
90+ }
91+ }
92+ acp_redirect('menus', array('loc' => $loc));
93+ }
94+
95+ if ($do === 'save') {
96+ $label = substr(trim((string)($_POST['label'] ?? '')), 0, 64);
97+ $url = substr(trim((string)($_POST['url'] ?? '')), 0, 255);
98+ $icon = substr(trim((string)($_POST['icon'] ?? '')), 0, 48);
99+ $target = ((string)($_POST['target'] ?? '') === '_blank') ? '_blank' : '';
100+ $visibility = (string)($_POST['visibility'] ?? 'all');
101+ $parent = intv($_POST['parent_id'] ?? 0);
102+ $order = intv($_POST['sort_order'] ?? 0);
103+ $existing = $id > 0 ? db()->fetchOne("
104+ SELECT `id`, `parent_id`, `location`
105+ FROM `znote_menu`
106+ WHERE `id` = ?
107+ LIMIT 1;
108+ ", [$id]) : false;
109+ $editingCategory = is_array($existing) && (int)$existing['parent_id'] === 0;
110+
111+ if (!isset(acp_menu_visibility()[$visibility])) {
112+ $visibility = 'all';
113+ }
114+ // A top-level entry is a category: a heading that opens its children,
115+ // not a link of its own. Everything nested under one still needs a URL.
116+ $isCategory = ($parent === 0);
117+
118+ if ($label === '') {
119+ acp_flash_error(t('acp.menu.label_required'));
120+ acp_redirect('menus', array('loc' => $loc));
121+ }
122+ if (!$isCategory && $url === '') {
123+ acp_flash_error(t('acp.menu.url_required'));
124+ acp_redirect('menus', array('loc' => $loc));
125+ }
126+
127+ if ($id > 0 && !is_array($existing)) {
128+ acp_flash_error(t('acp.menu.entry_gone'));
129+ acp_redirect('menus', array('loc' => $loc));
130+ }
131+
132+ if ($editingCategory) {
133+ $parent = 0;
134+ } elseif ($parent > 0) {
135+ $category = $parent > 0 ? db()->fetchOne("
136+ SELECT `id`
137+ FROM `znote_menu`
138+ WHERE `id` = ?
139+ AND `location` = ?
140+ AND `parent_id` = 0
141+ LIMIT 1;
142+ ", [$parent, $loc]) : false;
143+ if (!is_array($category)) {
144+ acp_flash_error(t('acp.menu.choose_category'));
145+ acp_redirect('menus', array('loc' => $loc));
146+ }
147+ }
148+
149+ $fieldsSql = "`label` = ?, `url` = ?, `icon` = ?, `target` = ?, `visibility` = ?, `parent_id` = ?, `sort_order` = ?";
150+ $fieldsParams = [$label, $url, $icon, $target, $visibility, $parent, $order];
151+
152+ if ($id > 0) {
153+ db()->execute("UPDATE `znote_menu` SET {$fieldsSql} WHERE `id` = ? LIMIT 1;", [...$fieldsParams, $id]);
154+ acp_log('menu.update', $label, ['url' => $url, 'location' => $loc]);
155+ acp_flash_success(t('acp.menu.updated'));
156+ } else {
157+ db()->execute("INSERT INTO `znote_menu` SET `location` = ?, `active` = 1, {$fieldsSql};", [$loc, ...$fieldsParams]);
158+ acp_log('menu.create', $label, ['url' => $url, 'location' => $loc]);
159+ acp_flash_success(t('acp.menu.added'));
160+ }
161+
162+ acp_redirect('menus', array('loc' => $loc));
163+ }
164+
165+ acp_flash_error(t('acp.menu.unknown_action'));
166+ acp_redirect('menus', array('loc' => $location));
167+}
168+
169+// ---------------------------------------------------------------------------
170+// Load
171+// ---------------------------------------------------------------------------
172+$entries = $hasTable ? db()->fetchAll("
173+ SELECT `id`, `parent_id`, `label`, `url`, `icon`, `target`, `visibility`, `sort_order`, `active`
174+ FROM `znote_menu`
175+ WHERE `location` = ?
176+ ORDER BY `sort_order` ASC, `id` ASC;
177+", [$location]) : false;
178+$entries = is_array($entries) ? $entries : array();
179+
180+$editing = null;
181+if (($_GET['action'] ?? '') === 'edit') {
182+ $editId = intv($_GET['id'] ?? 0);
183+ foreach ($entries as $entry) {
184+ if ((int)$entry['id'] === $editId) {
185+ $editing = $entry;
186+ break;
187+ }
188+ }
189+}
190+$editingCategory = is_array($editing) && (int)$editing['parent_id'] === 0;
191+
192+// Top-level entries, for the parent dropdown.
193+$parents = array();
194+foreach ($entries as $entry) {
195+ if ((int)$entry['parent_id'] === 0 && (int)$entry['id'] !== (int)($editing['id'] ?? 0)) {
196+ $parents[(int)$entry['id']] = (string)$entry['label'];
197+ }
198+}
199+
200+$topEntries = array();
201+$childrenByParent = array();
202+foreach ($entries as $entry) {
203+ $parentId = (int)$entry['parent_id'];
204+ if ($parentId === 0) {
205+ $topEntries[] = $entry;
206+ } else {
207+ $childrenByParent[$parentId][] = $entry;
208+ }
209+}
210+
211+$displayEntries = array();
212+foreach ($topEntries as $entry) {
213+ $displayEntries[] = $entry;
214+ foreach ($childrenByParent[(int)$entry['id']] ?? array() as $child) {
215+ $displayEntries[] = $child;
216+ }
217+}
218+foreach ($childrenByParent as $parentId => $children) {
219+ if (!isset($parents[(int)$parentId]) && (int)$parentId !== (int)($editing['id'] ?? 0)) {
220+ foreach ($children as $child) {
221+ $displayEntries[] = $child;
222+ }
223+ }
224+}
225+?>
226+
227+<?php if (!$hasTable): ?>
228+ <div class="acp-flash acp-flash--error">
229+ <i class="fa fa-exclamation-triangle"></i>
230+ <span>
231+ <?= t('acp.menu.table_missing', [
232+ 'table' => '<code>znote_menu</code>',
233+ 'file' => '<code>SQL/migrations/2.0.0_menus.sql</code>',
234+ ]) ?>
235+ </span>
236+ </div>
237+<?php endif; ?>
238+
239+<div class="acp-toolbar">
240+ <div class="acp-actions is-tight">
241+ <?php foreach ($locations as $slug => $label): ?>
242+ <a class="acp-btn <?= $slug === $location ? '' : 'acp-btn--ghost' ?> acp-btn--sm"
243+ href="<?= h(acp_url('menus', array('loc' => $slug))) ?>">
244+ <?= h($label) ?>
245+ </a>
246+ <?php endforeach; ?>
247+ </div>
248+ <span class="is-muted"><?= t('acp.menu.entries_in', ['n' => count($entries), 'location' => '<code>' . h($location) . '</code>']) ?></span>
249+</div>
250+
251+<div class="acp-grid acp-grid--2">
252+
253+ <section class="acp-card">
254+ <header class="acp-card-head">
255+ <h2><?= $editing !== null ? t('acp.menu.edit_entry') : t('acp.menu.add_entry') ?></h2>
256+ <p><?= t('acp.menu.to_location', ['location' => h($locations[$location])]) ?></p>
257+ </header>
258+ <div class="acp-card-body">
259+ <form method="post">
260+ <?= acp_csrf_field() ?>
261+ <input type="hidden" name="do" value="save">
262+ <input type="hidden" name="location" value="<?= h($location) ?>">
263+ <?php if ($editing !== null): ?>
264+ <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
265+ <?php endif; ?>
266+
267+ <div class="acp-row">
268+ <div class="acp-field">
269+ <label class="acp-label" for="label"><?= t('acp.menu.label') ?></label>
270+ <input class="acp-input" id="label" name="label" required
271+ value="<?= h((string)($editing['label'] ?? '')) ?>">
272+ </div>
273+ <div class="acp-field">
274+ <label class="acp-label" for="url"><?= t('acp.menu.url') ?></label>
275+ <input class="acp-input" id="url" name="url"
276+ placeholder="highscores.php"
277+ value="<?= h((string)($editing['url'] ?? '')) ?>">
278+ <p class="acp-hint"><?= t('acp.menu.url_hint') ?></p>
279+ </div>
280+ </div>
281+
282+ <div class="acp-row">
283+ <div class="acp-field">
284+ <label class="acp-label" for="icon"><?= t('acp.menu.icon') ?></label>
285+ <input class="acp-input" id="icon" name="icon" placeholder="fa-users"
286+ value="<?= h((string)($editing['icon'] ?? '')) ?>">
287+ <p class="acp-hint"><?= t('acp.menu.icon_hint') ?></p>
288+ </div>
289+ <div class="acp-field">
290+ <label class="acp-label" for="parent_id"><?= t('acp.menu.category') ?></label>
291+ <?php if ($editingCategory): ?>
292+ <input type="hidden" name="parent_id" value="0">
293+ <input class="acp-input" id="parent_id" value="<?= h(t('acp.menu.top_level')) ?>" disabled>
294+ <?php else: ?>
295+ <select class="acp-select" id="parent_id" name="parent_id" required>
296+ <option value="0" <?= (int)($editing['parent_id'] ?? 0) === 0 ? 'selected' : '' ?>><?= t('acp.menu.top_level') ?></option>
297+ <?php foreach ($parents as $pid => $plabel): ?>
298+ <option value="<?= $pid ?>" <?= (int)($editing['parent_id'] ?? 0) === $pid ? 'selected' : '' ?>>
299+ <?= h($plabel) ?>
300+ </option>
301+ <?php endforeach; ?>
302+ </select>
303+ <?php endif; ?>
304+ </div>
305+ </div>
306+
307+ <div class="acp-row">
308+ <div class="acp-field">
309+ <label class="acp-label" for="visibility"><?= t('acp.menu.shown_to') ?></label>
310+ <select class="acp-select" id="visibility" name="visibility">
311+ <?php foreach (acp_menu_visibility() as $value => $vlabel): ?>
312+ <option value="<?= h($value) ?>" <?= (string)($editing['visibility'] ?? 'all') === $value ? 'selected' : '' ?>>
313+ <?= h($vlabel) ?>
314+ </option>
315+ <?php endforeach; ?>
316+ </select>
317+ </div>
318+ <div class="acp-field">
319+ <label class="acp-label" for="target"><?= t('acp.menu.opens_in') ?></label>
320+ <select class="acp-select" id="target" name="target">
321+ <option value=""><?= t('acp.menu.same_tab') ?></option>
322+ <option value="_blank" <?= (string)($editing['target'] ?? '') === '_blank' ? 'selected' : '' ?>><?= t('acp.menu.new_tab') ?></option>
323+ </select>
324+ </div>
325+ <div class="acp-field">
326+ <label class="acp-label" for="sort_order"><?= t('acp.menu.order') ?></label>
327+ <input class="acp-input" id="sort_order" name="sort_order" type="number"
328+ value="<?= (int)($editing['sort_order'] ?? ((count($entries) + 1) * 10)) ?>">
329+ </div>
330+ </div>
331+
332+ <div class="acp-actions">
333+ <button class="acp-btn acp-btn--green" type="submit">
334+ <i class="fa fa-check"></i> <?= $editing !== null ? t('acp.menu.save_entry') : t('acp.menu.add_entry_btn') ?>
335+ </button>
336+ <?php if ($editing !== null): ?>
337+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('menus', array('loc' => $location))) ?>"><?= t('acp.menu.cancel') ?></a>
338+ <?php endif; ?>
339+ </div>
340+ </form>
341+ </div>
342+ </section>
343+
344+ <section class="acp-card">
345+ <header class="acp-card-head"><h2><?= t('acp.menu.how_title') ?></h2></header>
346+ <div class="acp-card-body">
347+ <p>
348+ <?= t('acp.menu.how_text1', ['json' => '<code>theme.json</code>']) ?>
349+ </p>
350+ <pre class="acp-dump">"menus": { "main": "Top navigation" }
351+
352+&lt;?php foreach (theme_menu_items('main') as $item): ?&gt;
353+ &lt;a href="&lt;?= $item['url'] ?&gt;"&gt;&lt;?= $item['label'] ?&gt;&lt;/a&gt;
354+&lt;?php endforeach; ?&gt;</pre>
355+ <p class="is-muted">
356+ <?= t('acp.menu.how_text2', ['admins_only' => '<em>' . t('acp.menu.admins_only') . '</em>']) ?>
357+ </p>
358+ <p class="is-muted">
359+ <?= t('acp.menu.how_text3') ?>
360+ </p>
361+ </div>
362+ </section>
363+</div>
364+
365+<section class="acp-card">
366+ <header class="acp-card-head">
367+ <h2><?= h($locations[$location]) ?></h2>
368+ <p><?= t('acp.menu.lower_first') ?></p>
369+ </header>
370+ <div class="acp-card-body is-flush">
371+ <?php if ($entries): ?>
372+ <div class="acp-table-wrap">
373+ <table class="acp-table">
374+ <thead>
375+ <tr>
376+ <th class="is-num"><?= t('acp.menu.col_order') ?></th>
377+ <th><?= t('acp.menu.col_label') ?></th>
378+ <th><?= t('acp.menu.col_url') ?></th>
379+ <th><?= t('acp.menu.col_shown_to') ?></th>
380+ <th class="is-num"><?= t('acp.menu.col_actions') ?></th>
381+ </tr>
382+ </thead>
383+ <tbody>
384+ <?php foreach ($displayEntries as $entry):
385+ $id = (int)$entry['id'];
386+ $child = (int)$entry['parent_id'] > 0;
387+ $hidden = ((int)$entry['active'] === 0);
388+ ?>
389+ <tr<?= $hidden ? ' style="opacity:.5;"' : '' ?>>
390+ <td class="is-num is-muted"><?= (int)$entry['sort_order'] ?></td>
391+ <td>
392+ <?= $child ? '<span class="is-muted">&mdash;&nbsp;</span>' : '' ?>
393+ <?php if ($entry['icon'] !== ''): ?>
394+ <i class="fa <?= h((string)$entry['icon']) ?> is-muted"></i>
395+ <?php endif; ?>
396+ <?= h((string)$entry['label']) ?>
397+ <?php if ($hidden): ?>
398+ <span class="acp-pill acp-pill--grey"><?= t('acp.menu.hidden_pill') ?></span>
399+ <?php endif; ?>
400+ </td>
401+ <td class="is-muted"><code><?= h((string)$entry['url']) ?></code></td>
402+ <td>
403+ <span class="acp-pill acp-pill--<?= $entry['visibility'] === 'admin' ? 'red' : ($entry['visibility'] === 'all' ? 'grey' : 'blue') ?>">
404+ <?= h(acp_menu_visibility()[$entry['visibility']] ?? $entry['visibility']) ?>
405+ </span>
406+ </td>
407+ <td class="is-num is-nowrap">
408+ <?php foreach (array('up' => ['fa-arrow-up', t('acp.menu.move_up')], 'down' => ['fa-arrow-down', t('acp.menu.move_down')]) as $dir => $meta): ?>
409+ <form class="acp-inline-form" method="post">
410+ <?= acp_csrf_field() ?>
411+ <input type="hidden" name="do" value="move">
412+ <input type="hidden" name="id" value="<?= $id ?>">
413+ <input type="hidden" name="dir" value="<?= $dir ?>">
414+ <input type="hidden" name="location" value="<?= h($location) ?>">
415+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit" title="<?= h($meta[1]) ?>">
416+ <i class="fa <?= $meta[0] ?>"></i>
417+ </button>
418+ </form>
419+ <?php endforeach; ?>
420+
421+ <form class="acp-inline-form" method="post">
422+ <?= acp_csrf_field() ?>
423+ <input type="hidden" name="do" value="toggle">
424+ <input type="hidden" name="id" value="<?= $id ?>">
425+ <input type="hidden" name="location" value="<?= h($location) ?>">
426+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit" title="<?= h($hidden ? t('acp.menu.show') : t('acp.menu.hide')) ?>">
427+ <i class="fa <?= $hidden ? 'fa-eye' : 'fa-eye-slash' ?>"></i>
428+ </button>
429+ </form>
430+
431+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('menus', array('loc' => $location, 'action' => 'edit', 'id' => $id))) ?>">
432+ <i class="fa fa-pencil"></i>
433+ </a>
434+
435+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t($child ? 'acp.menu.confirm_delete' : 'acp.menu.confirm_delete_category')) ?>">
436+ <?= acp_csrf_field() ?>
437+ <input type="hidden" name="do" value="delete">
438+ <input type="hidden" name="id" value="<?= $id ?>">
439+ <input type="hidden" name="location" value="<?= h($location) ?>">
440+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i></button>
441+ </form>
442+ </td>
443+ </tr>
444+ <?php endforeach; ?>
445+ </tbody>
446+ </table>
447+ </div>
448+ <?php else: ?>
449+ <?php acp_empty(t('acp.menu.empty'), 'fa-bars'); ?>
450+ <?php endif; ?>
451+ </div>
452+</section>
A admin/modules/migrations.php +187-0 View file
@@ -0,0 +1,187 @@
1+<?php
2+/**
3+ * Title: Migrations
4+ * Icon: fa-database
5+ * Group: Operations
6+ * Order: 30
7+ * Description: Apply SQL updates from SQL/migrations without phpMyAdmin.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_migration_state_label(string $state): string {
16+ switch ($state) {
17+ case 'applied': return 'Applied';
18+ case 'pending': return 'Pending';
19+ case 'changed': return 'Changed after apply';
20+ case 'missing': return 'Applied file missing';
21+ default: return ucfirst($state);
22+ }
23+}
24+
25+function acp_migration_state_class(string $state): string {
26+ switch ($state) {
27+ case 'applied': return 'green';
28+ case 'pending': return 'amber';
29+ case 'changed':
30+ case 'missing':
31+ return 'red';
32+ default: return 'grey';
33+ }
34+}
35+
36+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
37+ $action = (string)($_POST['do'] ?? '');
38+
39+ if ($action === 'run_pending') {
40+ $results = znote_migrations_run_pending();
41+ if (!$results) {
42+ acp_flash_info('No pending migrations.');
43+ } else {
44+ $ok = 0;
45+ $failed = '';
46+ foreach ($results as $name => $result) {
47+ if (!empty($result['ok'])) {
48+ $ok++;
49+ } else {
50+ $failed = $name . ': ' . ($result['message'] ?? 'failed');
51+ break;
52+ }
53+ }
54+
55+ if ($failed !== '') {
56+ acp_flash_error(h($failed));
57+ } else {
58+ acp_log('system.migrations', '', ['applied' => array_keys($results)]);
59+ acp_flash_success('Applied ' . $ok . ' migration' . ($ok === 1 ? '' : 's') . '.');
60+ }
61+ }
62+ acp_redirect('migrations');
63+ }
64+
65+ if ($action === 'run_one') {
66+ $name = basename((string)($_POST['migration'] ?? ''));
67+ $result = znote_migration_run($name);
68+ if (!empty($result['ok'])) {
69+ acp_log('system.migration', $name, ['statements' => $result['statements'] ?? 0]);
70+ acp_flash_success(h($name) . ': ' . h((string)$result['message']));
71+ } else {
72+ acp_flash_error(h($name) . ': ' . h((string)$result['message']));
73+ }
74+ acp_redirect('migrations');
75+ }
76+
77+ acp_redirect('migrations');
78+}
79+
80+$status = znote_migrations_status();
81+$pending = array_filter($status, static fn(array $row): bool => $row['state'] === 'pending');
82+$applied = array_filter($status, static fn(array $row): bool => $row['state'] === 'applied');
83+$warnings = array_filter($status, static fn(array $row): bool => in_array($row['state'], array('changed', 'missing'), true));
84+?>
85+
86+<div class="acp-stats">
87+ <?php
88+ acp_stat('Pending', count($pending), 'fa-clock-o', null, count($pending) ? 'amber' : 'green');
89+ acp_stat('Applied', count($applied), 'fa-check', null, 'blue');
90+ acp_stat('Warnings', count($warnings), 'fa-exclamation-triangle', null, count($warnings) ? 'red' : 'grey');
91+ ?>
92+</div>
93+
94+<section class="acp-card">
95+ <header class="acp-card-head">
96+ <h2>Database migrations</h2>
97+ <p>Runs SQL files from <code>SQL/migrations</code> and records successful executions in <code>znote_migrations</code>.</p>
98+ </header>
99+ <div class="acp-card-body">
100+ <?php if (!$status): ?>
101+ <?php acp_empty('No migration files found.', 'fa-database'); ?>
102+ <?php else: ?>
103+ <div class="acp-actions" style="margin-bottom:14px;">
104+ <form method="post" data-confirm="Run all pending migrations now?">
105+ <?= acp_csrf_field() ?>
106+ <input type="hidden" name="do" value="run_pending">
107+ <button class="acp-btn" type="submit" <?= !$pending ? 'disabled' : '' ?>>
108+ <i class="fa fa-play"></i> Run pending migrations
109+ </button>
110+ </form>
111+ </div>
112+
113+ <div class="acp-table-wrap">
114+ <table class="acp-table">
115+ <thead>
116+ <tr>
117+ <th>Migration</th>
118+ <th>Status</th>
119+ <th>Checksum</th>
120+ <th>Executed</th>
121+ <th>Time</th>
122+ <th style="width:1%">Action</th>
123+ </tr>
124+ </thead>
125+ <tbody>
126+ <?php foreach ($status as $row):
127+ $state = (string)$row['state'];
128+ $appliedRow = is_array($row['applied']) ? $row['applied'] : array();
129+ ?>
130+ <tr>
131+ <td>
132+ <strong><?= h($row['name']) ?></strong>
133+ <?php if (!empty($row['size'])): ?>
134+ <br><small><?= h(number_format((int)$row['size'])) ?> bytes</small>
135+ <?php endif; ?>
136+ </td>
137+ <td>
138+ <span class="acp-pill acp-pill--<?= h(acp_migration_state_class($state)) ?>">
139+ <?= h(acp_migration_state_label($state)) ?>
140+ </span>
141+ </td>
142+ <td><code title="<?= h($row['checksum']) ?>"><?= h(substr((string)$row['checksum'], 0, 12)) ?></code></td>
143+ <td>
144+ <?php if (!empty($appliedRow['executed_at'])): ?>
145+ <?= h(date('Y-m-d H:i:s', (int)$appliedRow['executed_at'])) ?>
146+ <?php else: ?>
147+ <span class="is-muted">Never</span>
148+ <?php endif; ?>
149+ </td>
150+ <td>
151+ <?php if (isset($appliedRow['execution_time_ms'])): ?>
152+ <?= h((string)(int)$appliedRow['execution_time_ms']) ?> ms
153+ <?php else: ?>
154+ <span class="is-muted">-</span>
155+ <?php endif; ?>
156+ </td>
157+ <td>
158+ <?php if ($state === 'pending'): ?>
159+ <form method="post" data-confirm="Run this migration now?">
160+ <?= acp_csrf_field() ?>
161+ <input type="hidden" name="do" value="run_one">
162+ <input type="hidden" name="migration" value="<?= h($row['name']) ?>">
163+ <button class="acp-btn acp-btn--sm" type="submit">Run</button>
164+ </form>
165+ <?php elseif ($state === 'changed'): ?>
166+ <span class="is-muted">File changed</span>
167+ <?php elseif ($state === 'missing'): ?>
168+ <span class="is-muted">Missing</span>
169+ <?php else: ?>
170+ <span class="is-muted">Done</span>
171+ <?php endif; ?>
172+ </td>
173+ </tr>
174+ <?php endforeach; ?>
175+ </tbody>
176+ </table>
177+ </div>
178+ <?php endif; ?>
179+ </div>
180+</section>
181+
182+<div class="acp-flash acp-flash--info">
183+ <i class="fa fa-info-circle"></i>
184+ <span>
185+ Use this page after pulling/updating ZnoteX. Payment IPNs and normal visitors never run migrations; only admins can apply them here.
186+ </span>
187+</div>
A admin/modules/minimap.php +203-0 View file
@@ -0,0 +1,203 @@
1+<?php
2+/**
3+ * Title: Minimap
4+ * Icon: fa-map-o
5+ * Group: Server Info
6+ * Order: 20
7+ * Description: Import an OTClient .otmm minimap and show it on Server Information.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
16+
17+ $do = (string)($_POST['do'] ?? '');
18+
19+ if ($do === 'delete') {
20+ minimap_delete();
21+ acp_log('minimap.remove');
22+ acp_flash_success(t('acp.map.removed'));
23+ acp_redirect('minimap');
24+ }
25+
26+ if ($do === 'upload') {
27+ $file = $_FILES['otmm'] ?? null;
28+
29+ if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
30+ acp_flash_error(t('acp.map.select_file'));
31+ acp_redirect('minimap');
32+ }
33+
34+ if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
35+ $reasons = [
36+ UPLOAD_ERR_INI_SIZE => t('acp.map.err_ini_size'),
37+ UPLOAD_ERR_FORM_SIZE => t('acp.map.err_form_size'),
38+ UPLOAD_ERR_PARTIAL => t('acp.map.err_partial'),
39+ UPLOAD_ERR_NO_TMP_DIR => t('acp.map.err_no_tmp_dir'),
40+ UPLOAD_ERR_CANT_WRITE => t('acp.map.err_cant_write'),
41+ UPLOAD_ERR_EXTENSION => t('acp.map.err_extension'),
42+ ];
43+ acp_flash_error($reasons[(int)$file['error']] ?? t('acp.map.upload_failed'));
44+ acp_redirect('minimap');
45+ }
46+
47+ if (!is_uploaded_file((string)$file['tmp_name'])) {
48+ acp_flash_error(t('acp.map.unverified'));
49+ acp_redirect('minimap');
50+ }
51+
52+ $error = null;
53+ if (minimap_import((string)$file['tmp_name'], (string)$file['name'], $error)) {
54+ $fresh = json_decode((string)file_get_contents(minimap_meta_path()), true);
55+ acp_log('minimap.import', (string)$file['name'], [
56+ 'tiles' => (int)($fresh['tiles'] ?? 0),
57+ 'floors' => count((array)($fresh['floors'] ?? [])),
58+ ]);
59+ acp_flash_success(t('acp.map.imported', [
60+ 'tiles' => number_format((int)($fresh['tiles'] ?? 0)),
61+ 'floors' => number_format(count((array)($fresh['floors'] ?? []))),
62+ ]));
63+ } else {
64+ acp_flash_error((string)$error);
65+ }
66+
67+ acp_redirect('minimap');
68+ }
69+}
70+
71+$minimap = minimap_data();
72+$root = minimap_root();
73+
74+$diskBytes = 0;
75+foreach (glob($root . '/*.png') ?: [] as $tile) {
76+ $diskBytes += (int)filesize($tile);
77+}
78+
79+$writable = is_dir($root) ? is_writable($root) : is_writable(dirname($root));
80+$maxUpload = min(
81+ (int)serverdata_ini_bytes((string)ini_get('upload_max_filesize')),
82+ (int)serverdata_ini_bytes((string)ini_get('post_max_size'))
83+);
84+?>
85+
86+<div class="acp-toolbar">
87+ <div>
88+ <?php if ($minimap !== false): ?>
89+ <span class="acp-pill acp-pill--green"><?= t('acp.map.active_pill') ?></span>
90+ <?php else: ?>
91+ <span class="acp-pill acp-pill--grey"><?= t('acp.map.none_pill') ?></span>
92+ <?php endif; ?>
93+ </div>
94+ <div class="acp-actions is-tight">
95+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('serverinfo.php')) ?>" target="_blank">
96+ <i class="fa fa-external-link"></i> <?= t('acp.map.view_serverinfo') ?>
97+ </a>
98+ </div>
99+</div>
100+
101+<div class="acp-grid acp-grid--2">
102+
103+ <section class="acp-card">
104+ <header class="acp-card-head">
105+ <h2><?= t('acp.map.import_title') ?></h2>
106+ <p><?= t('acp.map.import_sub', ['folder' => '<code>minimap</code>']) ?></p>
107+ </header>
108+ <div class="acp-card-body">
109+
110+ <?php if (!$writable): ?>
111+ <div class="acp-flash acp-flash--error">
112+ <i class="fa fa-exclamation-triangle"></i>
113+ <span><?= t('acp.map.not_writable', ['folder' => '<code>' . h(ZNOTE_MINIMAP_DIR) . '</code>']) ?></span>
114+ </div>
115+ <?php endif; ?>
116+
117+ <?php if (!extension_loaded('gd') || !extension_loaded('zlib')): ?>
118+ <div class="acp-flash acp-flash--error">
119+ <i class="fa fa-exclamation-triangle"></i>
120+ <span><?= t('acp.map.missing_ext', ['gd' => '<code>gd</code>', 'zlib' => '<code>zlib</code>']) ?></span>
121+ </div>
122+ <?php endif; ?>
123+
124+ <form method="post" enctype="multipart/form-data">
125+ <?= acp_csrf_field() ?>
126+ <input type="hidden" name="do" value="upload">
127+
128+ <div class="acp-field">
129+ <label class="acp-label" for="otmm"><?= t('acp.map.file_label') ?></label>
130+ <input class="acp-input" type="file" id="otmm" name="otmm" accept=".otmm" required>
131+ </div>
132+
133+ <div class="acp-actions">
134+ <button class="acp-btn acp-btn--green" type="submit" <?= $writable ? '' : 'disabled' ?>>
135+ <i class="fa fa-upload"></i> <?= t('acp.map.import_btn') ?>
136+ </button>
137+ <?php if ($minimap !== false): ?>
138+ <button class="acp-btn acp-btn--red" type="submit" form="minimapDelete">
139+ <i class="fa fa-trash"></i> <?= t('acp.map.remove_btn') ?>
140+ </button>
141+ <?php endif; ?>
142+ </div>
143+ </form>
144+
145+ <?php if ($minimap !== false): ?>
146+ <form method="post" id="minimapDelete" onsubmit="return confirm('<?= h(t('acp.map.confirm_remove')) ?>');">
147+ <?= acp_csrf_field() ?>
148+ <input type="hidden" name="do" value="delete">
149+ </form>
150+ <?php endif; ?>
151+
152+ <p class="acp-hint">
153+ <?= t('acp.map.upload_hint', [
154+ 'folder' => '<code>' . h(ZNOTE_MINIMAP_DIR) . '</code>',
155+ 'max' => (int)(ZNOTE_MINIMAP_MAX_BYTES / 1048576),
156+ 'accepts' => h(serverdata_human_size($maxUpload)),
157+ ]) ?>
158+ </p>
159+ </div>
160+ </section>
161+
162+ <section class="acp-card">
163+ <header class="acp-card-head">
164+ <h2><?= t('acp.map.status_title') ?></h2>
165+ <p><?= t('acp.map.status_sub') ?></p>
166+ </header>
167+ <div class="acp-card-body is-flush">
168+ <?php if ($minimap === false): ?>
169+ <div class="acp-card-body">
170+ <?php acp_empty(t('acp.map.none_body'), 'fa-map-o'); ?>
171+ </div>
172+ <?php else: ?>
173+ <div class="acp-table-wrap">
174+ <table class="acp-table">
175+ <tbody>
176+ <tr>
177+ <td><?= t('acp.map.col_source') ?></td>
178+ <td><code><?= h((string)($minimap['source'] ?? 'minimap.otmm')) ?></code></td>
179+ </tr>
180+ <tr>
181+ <td><?= t('acp.map.col_imported') ?></td>
182+ <td><?= h(date('Y-m-d H:i', (int)($minimap['date'] ?? 0))) ?></td>
183+ </tr>
184+ <tr>
185+ <td><?= t('acp.map.col_floors') ?></td>
186+ <td><?= h(implode(', ', array_map('intval', array_keys((array)$minimap['floors'])))) ?></td>
187+ </tr>
188+ <tr>
189+ <td><?= t('acp.map.col_tiles') ?></td>
190+ <td><?= number_format((int)($minimap['tiles'] ?? 0)) ?></td>
191+ </tr>
192+ <tr>
193+ <td><?= t('acp.map.col_disk') ?></td>
194+ <td><?= h(serverdata_human_size($diskBytes)) ?></td>
195+ </tr>
196+ </tbody>
197+ </table>
198+ </div>
199+ <?php endif; ?>
200+ </div>
201+ </section>
202+
203+</div>
A admin/modules/news.php +240-0 View file
@@ -0,0 +1,240 @@
1+<?php
2+/**
3+ * Title: News
4+ * Icon: fa-newspaper-o
5+ * Group: Content
6+ * Order: 10
7+ * Description: Write, edit and remove front-page articles.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_news_rebuild_cache(): void {
16+ $cache = new Cache('engine/cache/news');
17+ $cache->setContent(fetchAllNews() ?: []);
18+ $cache->save();
19+}
20+
21+// ---------------------------------------------------------------------------
22+// Mutations
23+// ---------------------------------------------------------------------------
24+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
25+
26+ $do = (string)($_POST['do'] ?? '');
27+ $id = intv($_POST['id'] ?? 0);
28+
29+ if ($do === 'delete' && $id > 0) {
30+ db()->execute("DELETE FROM `znote_news` WHERE `id` = ? LIMIT 1;", [$id]);
31+ acp_news_rebuild_cache();
32+ acp_log('news.delete', '#' . $id);
33+ acp_flash_success(t('acp.news.deleted'));
34+ acp_redirect('news');
35+ }
36+
37+ if ($do === 'create') {
38+ $charId = intv($_POST['selected_char'] ?? 0);
39+ $title = trim((string)($_POST['title'] ?? ''));
40+ $text = (string)($_POST['text'] ?? '');
41+
42+ if ($charId > 0 && $title !== '' && trim($text) !== '') {
43+ db()->execute("
44+ INSERT INTO `znote_news` (`title`, `text`, `date`, `pid`)
45+ VALUES (?, ?, ?, ?);
46+ ", [$title, $text, time(), $charId]);
47+ acp_news_rebuild_cache();
48+ acp_log('news.create', $title);
49+ acp_flash_success(t('acp.news.published'));
50+ acp_redirect('news');
51+ }
52+
53+ acp_flash_error(t('acp.news.fill_fields'));
54+ acp_redirect('news', ['action' => 'add']);
55+ }
56+
57+ if ($do === 'update' && $id > 0) {
58+ $title = trim((string)($_POST['title'] ?? ''));
59+ $text = (string)($_POST['text'] ?? '');
60+
61+ db()->execute("
62+ UPDATE `znote_news`
63+ SET `title` = ?, `text` = ?
64+ WHERE `id` = ?
65+ LIMIT 1;
66+ ", [$title, $text, $id]);
67+ acp_news_rebuild_cache();
68+ acp_log('news.update', $title !== '' ? $title : ('#' . $id));
69+ acp_flash_success(t('acp.news.updated'));
70+ acp_redirect('news');
71+ }
72+}
73+
74+// ---------------------------------------------------------------------------
75+// View state
76+// ---------------------------------------------------------------------------
77+$action = (string)($_GET['action'] ?? '');
78+$editId = intv($_GET['id'] ?? 0);
79+
80+$news = fetchAllNews();
81+$news = is_array($news) ? $news : [];
82+
83+$editing = null;
84+if ($action === 'edit' && $editId > 0) {
85+ foreach ($news as $n) {
86+ if ((int)$n['id'] === $editId) {
87+ $editing = $n;
88+ break;
89+ }
90+ }
91+ if ($editing === null) {
92+ acp_flash_error(t('acp.news.not_found'));
93+ acp_redirect('news');
94+ }
95+}
96+
97+// Staff characters on the admin's own account can be set as author.
98+$authors = [];
99+if ($action === 'add') {
100+ $charList = user_character_list($user_data['id']);
101+ if (is_array($charList)) {
102+ foreach ($charList as $row) {
103+ $charName = (string)($row['name'] ?? '');
104+ if ($charName === '') {
105+ continue;
106+ }
107+ $charId = (int)user_character_id($charName);
108+ $charD = user_character_data($charId, 'group_id', 'id');
109+ if (is_array($charD) && (int)($charD['group_id'] ?? 0) > 1) {
110+ $authors[$charId] = $charName;
111+ }
112+ }
113+ }
114+}
115+
116+?>
117+
118+<div class="acp-toolbar">
119+ <div>
120+ <?php if ($action === 'add'): ?>
121+ <strong><?= t('acp.news.new_article') ?></strong>
122+ <?php elseif ($editing !== null): ?>
123+ <strong><?= t('acp.news.editing') ?></strong> <?= h((string)$editing['title']) ?>
124+ <?php else: ?>
125+ <span class="acp-pill acp-pill--grey"><?= t('acp.news.published_count', ['count' => count($news)]) ?></span>
126+ <?php endif; ?>
127+ </div>
128+ <div class="acp-actions is-tight">
129+ <?php if ($action !== ''): ?>
130+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('news')) ?>"><i class="fa fa-arrow-left"></i> <?= t('acp.news.back_to_list') ?></a>
131+ <?php else: ?>
132+ <a class="acp-btn" href="<?= h(acp_url('news', ['action' => 'add'])) ?>"><i class="fa fa-plus"></i> <?= t('acp.news.create_article') ?></a>
133+ <?php endif; ?>
134+ </div>
135+</div>
136+
137+<?php if ($action === 'add' || $editing !== null): ?>
138+ <section class="acp-card">
139+ <header class="acp-card-head">
140+ <h2><?= $editing !== null ? t('acp.news.edit_article') : t('acp.news.new_article') ?></h2>
141+ </header>
142+ <div class="acp-card-body">
143+
144+ <?php if ($action === 'add' && !$authors): ?>
145+ <div class="acp-flash acp-flash--error">
146+ <i class="fa fa-exclamation-triangle"></i>
147+ <span>
148+ <?= t('acp.news.no_author', ['code' => '<code>group_id &gt; 1</code>']) ?>
149+ </span>
150+ </div>
151+ <?php endif; ?>
152+
153+ <form method="post">
154+ <?= acp_csrf_field() ?>
155+ <input type="hidden" name="do" value="<?= $editing !== null ? 'update' : 'create' ?>">
156+ <?php if ($editing !== null): ?>
157+ <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
158+ <?php endif; ?>
159+
160+ <?php if ($action === 'add'): ?>
161+ <div class="acp-field">
162+ <label class="acp-label" for="selected_char"><?= t('acp.news.publish_as') ?></label>
163+ <select class="acp-select" id="selected_char" name="selected_char" <?= $authors ? '' : 'disabled' ?>>
164+ <?php foreach ($authors as $charId => $charName): ?>
165+ <option value="<?= (int)$charId ?>"><?= h($charName) ?></option>
166+ <?php endforeach; ?>
167+ </select>
168+ </div>
169+ <?php endif; ?>
170+
171+ <div class="acp-field">
172+ <label class="acp-label" for="title"><?= t('acp.news.title') ?></label>
173+ <input class="acp-input" id="title" name="title" value="<?= $editing !== null ? h((string)$editing['title']) : '' ?>" required>
174+ </div>
175+
176+ <div class="acp-field">
177+ <label class="acp-label" for="text"><?= t('acp.news.body') ?></label>
178+ <?php acp_editor('text', $editing !== null ? (string)$editing['text'] : '', ['height' => 340]); ?>
179+ </div>
180+
181+ <div class="acp-actions">
182+ <button class="acp-btn acp-btn--green" type="submit" <?= ($action === 'add' && !$authors) ? 'disabled' : '' ?>>
183+ <i class="fa fa-check"></i> <?= $editing !== null ? t('acp.news.save_changes') : t('acp.news.publish_article') ?>
184+ </button>
185+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('news')) ?>"><?= t('acp.news.cancel') ?></a>
186+ </div>
187+ </form>
188+ </div>
189+ </section>
190+
191+<?php else: ?>
192+
193+ <section class="acp-card">
194+ <header class="acp-card-head"><h2><?= t('acp.news.published_articles') ?></h2></header>
195+ <div class="acp-card-body is-flush">
196+ <?php if ($news): ?>
197+ <div class="acp-table-wrap">
198+ <table class="acp-table" data-sortable>
199+ <thead>
200+ <tr>
201+ <th><?= t('acp.news.col_date') ?></th>
202+ <th><?= t('acp.news.col_author') ?></th>
203+ <th><?= t('acp.news.col_title') ?></th>
204+ <th class="is-num"><?= t('acp.news.col_actions') ?></th>
205+ </tr>
206+ </thead>
207+ <tbody>
208+ <?php foreach ($news as $n):
209+ $id = (int)($n['id'] ?? 0);
210+ $nm = (string)($n['name'] ?? '');
211+ ?>
212+ <tr>
213+ <td class="is-nowrap is-muted"><?= h(getClock((int)($n['date'] ?? 0), true)) ?></td>
214+ <td>
215+ <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode($nm))) ?>" target="_blank" rel="noopener"><?= h($nm) ?></a>
216+ </td>
217+ <td><?= h((string)($n['title'] ?? '')) ?></td>
218+ <td class="is-num is-nowrap">
219+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('news', ['action' => 'edit', 'id' => $id])) ?>">
220+ <i class="fa fa-pencil"></i> <?= t('acp.news.edit') ?>
221+ </a>
222+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.news.confirm_delete')) ?>">
223+ <?= acp_csrf_field() ?>
224+ <input type="hidden" name="do" value="delete">
225+ <input type="hidden" name="id" value="<?= $id ?>">
226+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i> <?= t('acp.news.delete') ?></button>
227+ </form>
228+ </td>
229+ </tr>
230+ <?php endforeach; ?>
231+ </tbody>
232+ </table>
233+ </div>
234+ <?php else: ?>
235+ <?php acp_empty(t('acp.news.empty'), 'fa-newspaper-o'); ?>
236+ <?php endif; ?>
237+ </div>
238+ </section>
239+
240+<?php endif; ?>
A admin/modules/payments.php +360-0 View file
@@ -0,0 +1,360 @@
1+<?php
2+/**
3+ * Title: Payments
4+ * Icon: fa-credit-card
5+ * Group: Economy
6+ * Order: 15
7+ * Description: Gateways, credentials and the point packages players can buy.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_payments_schema(): array {
16+ return require __DIR__ . '/_partials/payments_schema.php';
17+}
18+
19+function acp_payment_cast(string $type, $raw): string {
20+ switch ($type) {
21+ case 'bool': return empty($raw) ? '0' : '1';
22+ case 'int': return (string)intv($raw);
23+ default: return trim((string)$raw);
24+ }
25+}
26+
27+// ---------------------------------------------------------------------------
28+// Save
29+// ---------------------------------------------------------------------------
30+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
31+
32+ $saved = 0;
33+ $failed = 0;
34+ $savedKeys = array();
35+
36+ if (isset($_POST['pay']) && is_array($_POST['pay'])) {
37+ $knownFields = array();
38+ foreach (acp_payments_schema() as $group) {
39+ $knownFields += $group['fields'];
40+ }
41+
42+ foreach ($_POST['pay'] as $key => $raw) {
43+ if (!isset($knownFields[$key])) {
44+ continue;
45+ }
46+ $field = $knownFields[$key];
47+ if (($field['type'] ?? '') === 'secret' && trim((string)$raw) === '') {
48+ continue;
49+ }
50+ $value = acp_payment_cast($field['type'], $raw);
51+ if (setting_set('config:' . $key, $value)) {
52+ $saved++;
53+ $savedKeys[] = $key;
54+ } else $failed++;
55+ }
56+ }
57+
58+ // Price tiers arrive as parallel arrays and are stored as one JSON row,
59+ // because the whole list is a single config value.
60+ if (isset($_POST['tier_price']) && is_array($_POST['tier_price'])) {
61+ $tiers = array();
62+ $points = isset($_POST['tier_points']) && is_array($_POST['tier_points']) ? $_POST['tier_points'] : array();
63+
64+ foreach ($_POST['tier_price'] as $i => $price) {
65+ $price = is_numeric($price) ? round((float)$price, 2) : 0;
66+ $give = intv($points[$i] ?? 0);
67+ if ($price > 0 && $give > 0) {
68+ $tiers[number_format($price, 2, '.', '')] = $give;
69+ }
70+ }
71+
72+ ksort($tiers, SORT_NUMERIC);
73+ if (setting_set('config:paypal_prices', json_encode($tiers, JSON_FORCE_OBJECT))) {
74+ $saved++;
75+ $savedKeys[] = 'paypal_prices';
76+ } else $failed++;
77+ }
78+
79+ if ($saved > 0) {
80+ acp_log('payments.save', '', ['fields' => $savedKeys, 'failed' => $failed]);
81+ }
82+ if ($failed > 0) {
83+ acp_flash_error(t('acp.pay.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>']));
84+ } else {
85+ acp_flash_success(t('acp.pay.save_success', ['n' => $saved]));
86+ }
87+
88+ acp_redirect('payments');
89+}
90+
91+$schema = acp_payments_schema();
92+$hasTable = znote_table_exists('znote_config');
93+
94+if (function_exists('payment_gateway_ensure_schema')) {
95+ payment_gateway_ensure_schema();
96+}
97+
98+$storedTiers = setting('config:paypal_prices', null);
99+$tiers = null;
100+if ($storedTiers !== null) {
101+ $decoded = json_decode($storedTiers, true);
102+ if (is_array($decoded)) {
103+ $tiers = $decoded;
104+ }
105+}
106+if ($tiers === null) {
107+ $tiers = is_array($config['paypal_prices'] ?? null) ? $config['paypal_prices'] : array();
108+}
109+ksort($tiers, SORT_NUMERIC);
110+
111+$perCurrency = (int)znote_config_path($config, 'paypal.points_per_currency', 0);
112+$currency = (string)znote_config_path($config, 'paypal.currency', '');
113+$paymentPage = max(1, intv($_GET['page'] ?? 1));
114+$paymentPerPage = 50;
115+$paymentTotal = znote_table_exists('znote_payment_transactions')
116+ ? acp_count("SELECT COUNT(*) AS `c` FROM `znote_payment_transactions`;")
117+ : 0;
118+$paymentPages = max(1, (int)ceil($paymentTotal / $paymentPerPage));
119+$paymentPage = min($paymentPage, $paymentPages);
120+$paymentOffset = ($paymentPage - 1) * $paymentPerPage;
121+$modernPayments = znote_table_exists('znote_payment_transactions')
122+ ? db()->fetchAll("
123+ SELECT `provider`, `reference`, `provider_reference`, `account_id`, `price`, `currency`, `points`, `status`, `credited`, `test_mode`, `created_at`, `credited_at`
124+ FROM `znote_payment_transactions`
125+ ORDER BY `id` DESC
126+ LIMIT {$paymentOffset}, {$paymentPerPage};
127+ ")
128+ : false;
129+$paymentEvents = znote_table_exists('znote_payment_events')
130+ ? db()->fetchAll("
131+ SELECT `provider`, `event_id`, `provider_reference`, `payment_reference`, `status`, `received_at`
132+ FROM `znote_payment_events`
133+ ORDER BY `id` DESC
134+ LIMIT 50;
135+ ")
136+ : false;
137+?>
138+
139+<?php if (!$hasTable): ?>
140+ <div class="acp-flash acp-flash--error">
141+ <i class="fa fa-exclamation-triangle"></i>
142+ <span><?= t('acp.pay.table_missing_short', ['table' => '<code>znote_config</code>']) ?></span>
143+ </div>
144+<?php endif; ?>
145+
146+<div class="acp-flash acp-flash--info">
147+ <i class="fa fa-info-circle"></i>
148+ <span>
149+ <?= t('acp.pay.stored_note', [
150+ 'configphp' => '<code>config.php</code>',
151+ 'znoteconfig' => '<code>znote_config</code>',
152+ 'configphp2' => '<code>config.php</code>',
153+ ]) ?>
154+ </span>
155+</div>
156+
157+<section class="acp-card">
158+ <header class="acp-card-head">
159+ <h2><i class="fa fa-link"></i> Webhook URLs</h2>
160+ <p>Configure these URLs in Stripe and Mercado Pago. Return pages do not credit points.</p>
161+ </header>
162+ <div class="acp-card-body">
163+ <div class="acp-field">
164+ <label class="acp-label">Stripe</label>
165+ <input class="acp-input" type="text" readonly value="<?= h(function_exists('payment_gateway_webhook_url') ? payment_gateway_webhook_url('stripe') : '') ?>">
166+ </div>
167+ <div class="acp-field">
168+ <label class="acp-label">Mercado Pago</label>
169+ <input class="acp-input" type="text" readonly value="<?= h(function_exists('payment_gateway_webhook_url') ? payment_gateway_webhook_url('mercadopago') : '') ?>">
170+ </div>
171+ </div>
172+</section>
173+
174+<form method="post">
175+ <?= acp_csrf_field() ?>
176+
177+ <section class="acp-card">
178+ <header class="acp-card-head">
179+ <h2><i class="fa fa-shopping-cart"></i> <?= t('acp.pay.tiers_title') ?></h2>
180+ <p><?= t('acp.pay.tiers_sub') ?></p>
181+ </header>
182+ <div class="acp-card-body">
183+ <div class="acp-table-wrap">
184+ <table class="acp-table" id="tierTable">
185+ <tr class="yellow">
186+ <td><?= t('acp.pay.col_price') ?><?= $currency !== '' ? ' (' . h($currency) . ')' : '' ?></td>
187+ <td><?= t('acp.pay.col_points') ?></td>
188+ <td><?= t('acp.pay.col_bonus') ?></td>
189+ <td></td>
190+ </tr>
191+ <?php $rows = $tiers ?: array('' => ''); ?>
192+ <?php foreach ($rows as $price => $points): ?>
193+ <?php
194+ $bonus = null;
195+ if ($perCurrency > 0 && (float)$price > 0 && (int)$points > 0) {
196+ $bonus = round(((int)$points / ((float)$price * $perCurrency) - 1) * 100);
197+ }
198+ ?>
199+ <tr>
200+ <td><input class="acp-input" type="number" min="0" step="0.01" name="tier_price[]" value="<?= h($price) ?>"></td>
201+ <td><input class="acp-input" type="number" min="0" name="tier_points[]" value="<?= h($points) ?>"></td>
202+ <td class="is-muted"><?= $bonus === null ? '&mdash;' : ($bonus > 0 ? '+' : '') . (int)$bonus . '%' ?></td>
203+ <td><button type="button" class="acp-btn acp-btn--ghost" onclick="this.closest('tr').remove()"><i class="fa fa-times"></i></button></td>
204+ </tr>
205+ <?php endforeach; ?>
206+ </table>
207+ </div>
208+ <div class="acp-actions">
209+ <button type="button" class="acp-btn acp-btn--ghost" onclick="addTier()"><i class="fa fa-plus"></i> <?= t('acp.pay.add_package') ?></button>
210+ </div>
211+ <p class="acp-hint"><?= t('acp.pay.tiers_hint') ?></p>
212+ <div class="acp-actions">
213+ <button class="acp-btn acp-btn--green" type="submit" <?= $hasTable ? '' : 'disabled' ?>>
214+ <i class="fa fa-check"></i> <?= t('acp.pay.save_btn') ?>
215+ </button>
216+ </div>
217+ </div>
218+ </section>
219+</form>
220+
221+<div class="acp-grid acp-grid--2">
222+ <?php foreach ($schema as $groupName => $group): ?>
223+ <form method="post">
224+ <?= acp_csrf_field() ?>
225+ <section class="acp-card">
226+ <header class="acp-card-head">
227+ <h2><i class="fa <?= h($group['icon']) ?>"></i> <?= h($groupName) ?></h2>
228+ <?php if (!empty($group['help'])): ?><p><?= h($group['help']) ?></p><?php endif; ?>
229+ </header>
230+ <div class="acp-card-body">
231+ <?php foreach ($group['fields'] as $key => $field): ?>
232+ <?php
233+ $stored = setting('config:' . $key, null);
234+ $fromFile = znote_config_path($config, $key, '');
235+ if (is_bool($fromFile)) {
236+ $fromFile = $fromFile ? '1' : '0';
237+ } elseif (is_array($fromFile)) {
238+ $fromFile = '';
239+ }
240+ $current = ($stored !== null) ? $stored : (string)$fromFile;
241+ $isSecret = ($field['type'] ?? '') === 'secret';
242+ ?>
243+ <div class="acp-field">
244+ <label class="acp-label" for="pay_<?= h($key) ?>">
245+ <?= h($field['label']) ?>
246+ <?php if ($stored === null): ?>
247+ <span class="acp-pill acp-pill--grey" title="<?= h(t('acp.pay.following_title')) ?>"><?= t('acp.pay.file_pill') ?></span>
248+ <?php endif; ?>
249+ </label>
250+
251+ <?php if ($field['type'] === 'bool'): ?>
252+ <label style="display:flex;align-items:center;gap:8px;font-weight:400;">
253+ <input type="hidden" name="pay[<?= h($key) ?>]" value="0">
254+ <input type="checkbox" id="pay_<?= h($key) ?>" name="pay[<?= h($key) ?>]" value="1"
255+ <?= ($current !== '' && $current !== '0') ? 'checked' : '' ?>>
256+ <span class="is-muted"><?= t('acp.pay.enabled') ?></span>
257+ </label>
258+ <?php else: ?>
259+ <input class="acp-input" id="pay_<?= h($key) ?>" name="pay[<?= h($key) ?>]"
260+ type="<?= $field['type'] === 'int' ? 'number' : ($field['type'] === 'secret' ? 'password' : 'text') ?>"
261+ value="<?= $isSecret ? '' : h($current) ?>"
262+ <?= $isSecret ? 'autocomplete="new-password" placeholder="' . h($current !== '' ? 'Configured - leave blank to keep' : '') . '"' : '' ?>>
263+ <?php endif; ?>
264+
265+ <?php if (!empty($field['help'])): ?>
266+ <p class="acp-hint"><?= h($field['help']) ?></p>
267+ <?php endif; ?>
268+ </div>
269+ <?php endforeach; ?>
270+
271+ <div class="acp-actions">
272+ <button class="acp-btn acp-btn--green" type="submit" <?= $hasTable ? '' : 'disabled' ?>>
273+ <i class="fa fa-check"></i> <?= t('acp.pay.save_btn') ?>
274+ </button>
275+ </div>
276+ </div>
277+ </section>
278+ </form>
279+ <?php endforeach; ?>
280+</div>
281+
282+<section class="acp-card">
283+ <header class="acp-card-head">
284+ <h2><i class="fa fa-history"></i> Recent Stripe / Mercado Pago Transactions</h2>
285+ <p>Credited means the webhook has passed signature and provider verification.</p>
286+ </header>
287+ <div class="acp-card-body">
288+ <?php if (is_array($modernPayments) && $modernPayments): ?>
289+ <div class="acp-table-wrap">
290+ <table class="acp-table">
291+ <thead><tr><th>Provider</th><th>Reference</th><th>Account</th><th>Amount</th><th class="is-num">Points</th><th>Status</th><th>Created</th><th>Credited</th></tr></thead>
292+ <tbody>
293+ <?php foreach ($modernPayments as $row): ?>
294+ <tr>
295+ <td><?= h(ucfirst((string)$row['provider'])) ?><?= !empty($row['test_mode']) ? ' <span class="acp-pill acp-pill--grey">test</span>' : '' ?></td>
296+ <td><code title="<?= h((string)$row['provider_reference']) ?>"><?= h((string)$row['reference']) ?></code></td>
297+ <td class="is-num"><?= (int)$row['account_id'] ?></td>
298+ <td><?= h(number_format((float)$row['price'], 2, '.', '')) ?> <?= h($row['currency']) ?></td>
299+ <td class="is-num"><?= (int)$row['points'] ?></td>
300+ <td><?= h($row['status']) ?><?= !empty($row['credited']) ? ' <span class="acp-pill acp-pill--green">credited</span>' : '' ?></td>
301+ <td><?= !empty($row['created_at']) ? date('Y-m-d H:i', (int)$row['created_at']) : '-' ?></td>
302+ <td><?= !empty($row['credited_at']) ? date('Y-m-d H:i', (int)$row['credited_at']) : '-' ?></td>
303+ </tr>
304+ <?php endforeach; ?>
305+ </tbody>
306+ </table>
307+ </div>
308+ <?php else: ?>
309+ <?php acp_empty('No Stripe or Mercado Pago transaction yet.', 'fa-credit-card'); ?>
310+ <?php endif; ?>
311+ <?php if ($paymentPages > 1): ?>
312+ <div class="acp-toolbar">
313+ <span class="is-muted"><?= $paymentPage ?> / <?= $paymentPages ?></span>
314+ <div class="acp-actions is-tight">
315+ <?php if ($paymentPage > 1): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('payments', ['page' => $paymentPage - 1])) ?>"><?= t('common.previous') ?></a><?php endif; ?>
316+ <?php if ($paymentPage < $paymentPages): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('payments', ['page' => $paymentPage + 1])) ?>"><?= t('common.next') ?></a><?php endif; ?>
317+ </div>
318+ </div>
319+ <?php endif; ?>
320+ </div>
321+</section>
322+
323+<section class="acp-card">
324+ <header class="acp-card-head">
325+ <h2><i class="fa fa-exchange"></i> Recent webhook events</h2>
326+ </header>
327+ <div class="acp-card-body is-flush">
328+ <?php if (is_array($paymentEvents) && $paymentEvents): ?>
329+ <div class="acp-table-wrap">
330+ <table class="acp-table">
331+ <thead><tr><th>Provider</th><th>Event</th><th>Payment</th><th>Status</th><th>Received</th></tr></thead>
332+ <tbody>
333+ <?php foreach ($paymentEvents as $event): ?>
334+ <tr>
335+ <td><?= h(ucfirst((string)$event['provider'])) ?></td>
336+ <td><code><?= h((string)$event['event_id']) ?></code></td>
337+ <td><code title="<?= h((string)$event['provider_reference']) ?>"><?= h((string)$event['payment_reference']) ?></code></td>
338+ <td><?= h((string)$event['status']) ?></td>
339+ <td><?= date('Y-m-d H:i', (int)$event['received_at']) ?></td>
340+ </tr>
341+ <?php endforeach; ?>
342+ </tbody>
343+ </table>
344+ </div>
345+ <?php else: ?>
346+ <?php acp_empty('No webhook event yet.', 'fa-exchange'); ?>
347+ <?php endif; ?>
348+ </div>
349+</section>
350+
351+<script>
352+function addTier() {
353+ var table = document.getElementById('tierTable');
354+ var row = table.insertRow(-1);
355+ row.innerHTML = '<td><input class="acp-input" type="number" min="0" step="0.01" name="tier_price[]" value=""></td>'
356+ + '<td><input class="acp-input" type="number" min="0" name="tier_points[]" value=""></td>'
357+ + '<td class="is-muted">&mdash;</td>'
358+ + '<td><button type="button" class="acp-btn acp-btn--ghost" onclick="this.closest(\'tr\').remove()"><i class="fa fa-times"></i></button></td>';
359+}
360+</script>
A admin/modules/players.php +529-0 View file
@@ -0,0 +1,529 @@
1+<?php
2+/**
3+ * Title: Player Tools
4+ * Icon: fa-users
5+ * Group: Players
6+ * Order: 20
7+ * Description: Ban, punish, move and maintain characters and their accounts.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+// Select values are offset by this so a "0" option is still truthy in the
16+// original admin.php handler. Kept as-is so behaviour does not change.
17+$enc = 100;
18+
19+// Every engine except TFS_03 uses the plain (unsalted) password change path.
20+$isNotTfs03 = (znote_server_adapter()->normalizedEngine() !== 'TFS_03');
21+
22+function acp_players_table_exists(string $table): bool {
23+ $escaped = db()->connection()->real_escape_string($table);
24+ return db()->rawFetchOne("SHOW TABLES LIKE '{$escaped}';") !== false;
25+}
26+
27+function acp_players_column_exists(string $table, string $column): bool {
28+ $escaped = db()->connection()->real_escape_string($column);
29+ return db()->rawFetchOne("
30+ SHOW COLUMNS FROM `" . esc($table) . "`
31+ LIKE '{$escaped}';
32+ ") !== false;
33+}
34+
35+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
36+
37+ // ------------------------------------------------- Rule violation / ban
38+ if (!empty($_POST['ban_char'])) {
39+ $char = trim((string)$_POST['ban_char']);
40+
41+ if (user_character_exist($char)) {
42+ $type = intv($_POST['ban_type'] ?? 0) - $enc;
43+ $action = intv($_POST['ban_action'] ?? 0) - $enc;
44+ $reason = intv($_POST['ban_reason'] ?? 0) - $enc;
45+ $comment = substr(trim((string)($_POST['ban_comment'] ?? '')), 0, 60);
46+
47+ $banUnitSeconds = ['minutes' => 60, 'hours' => 3600, 'days' => 86400, 'weeks' => 604800];
48+ $banUnit = (string)($_POST['ban_duration_unit'] ?? 'hours');
49+ $banValue = max(0, intv($_POST['ban_duration_value'] ?? 0));
50+ $forever = !empty($_POST['ban_forever']);
51+
52+ if (!$forever && $banValue <= 0) {
53+ acp_flash_error(t('acp.plr.err_invalid_duration'));
54+ acp_redirect('players');
55+ }
56+
57+ $time = $forever ? null : ($banValue * ($banUnitSeconds[$banUnit] ?? 3600));
58+
59+ if (set_rule_violation($char, $type, $action, $reason, $time, $comment)) {
60+ acp_log('player.violation', $char, [
61+ 'type' => $type, 'action' => $action, 'reason' => $reason,
62+ 'time' => $forever ? 'forever' : $time, 'comment' => $comment,
63+ ]);
64+ acp_flash_success(t('acp.plr.violation_set', ['char' => h($char)]));
65+ } else {
66+ acp_flash_error(t('acp.plr.violation_failed'));
67+ }
68+ } else {
69+ acp_flash_error(t('acp.plr.char_not_exist', ['char' => h($char)]));
70+ }
71+
72+ acp_redirect('players');
73+ }
74+
75+ // ----------------------------------------------------- Delete character
76+ if (!empty($_POST['del_name'])) {
77+ $char = trim((string)$_POST['del_name']);
78+
79+ if (user_character_exist($char)) {
80+ user_delete_character(user_character_id($char));
81+ acp_log('player.delete_char', $char);
82+ acp_flash_success(t('acp.plr.char_deleted', ['char' => h($char)]));
83+ } else {
84+ acp_flash_error(t('acp.plr.char_not_exist', ['char' => h($char)]));
85+ }
86+
87+ acp_redirect('players');
88+ }
89+
90+ // ------------------------------------------------------- Reset password
91+ if (!empty($_POST['reset_pass']) && !empty($_POST['new_pass'])) {
92+ $char = trim((string)$_POST['reset_pass']);
93+
94+ if (user_character_exist($char)) {
95+ $accId = user_character_account_id($char);
96+
97+ // Changing your own password goes through the normal page, so the
98+ // session stays consistent.
99+ if ($accId !== $session_user_id) {
100+ if ($isNotTfs03) {
101+ user_change_password($accId, $_POST['new_pass']);
102+ } else {
103+ user_change_password03($accId, $_POST['new_pass']);
104+ }
105+ acp_log('player.reset_password', $char);
106+ acp_flash_success(t('acp.plr.password_reset', ['char' => h($char)]));
107+ acp_redirect('players');
108+ }
109+
110+ header('Location: ../changepassword.php');
111+ exit;
112+ }
113+
114+ acp_flash_error(t('acp.plr.char_not_exist', ['char' => h($char)]));
115+ acp_redirect('players');
116+ }
117+
118+ // ---------------------------------------------------- Rename character
119+ if (!empty($_POST['rename_character'])) {
120+ $currentName = trim((string)($_POST['rename_current'] ?? ''));
121+ $newNameInput = trim((string)($_POST['rename_new'] ?? ''));
122+ $newName = validate_name($newNameInput);
123+ $problems = [];
124+
125+ $onlineSelect = acp_players_column_exists('players', 'online') ? ', `online`' : '';
126+ $character = $currentName !== '' ? db()->fetchOne("
127+ SELECT `id`, `name`{$onlineSelect}
128+ FROM `players`
129+ WHERE `name` = ?
130+ LIMIT 1;
131+ ", [$currentName]) : false;
132+
133+ if (!is_array($character)) {
134+ $problems[] = t('acp.plr.err_current_not_exist');
135+ }
136+ if ($newName === false) {
137+ $problems[] = t('acp.plr.err_too_many_words');
138+ } else {
139+ $newName = format_character_name($newName);
140+ if (!preg_match('/^[a-zA-Z ]+$/', $newName)) {
141+ $problems[] = t('acp.plr.err_letters_only');
142+ }
143+ $minLength = (int)($config['minL'] ?? 3);
144+ $maxLength = (int)($config['maxL'] ?? 20);
145+ if (strlen($newName) < $minLength || strlen($newName) > $maxLength) {
146+ $problems[] = t('acp.plr.err_length', ['min' => $minLength, 'max' => $maxLength]);
147+ }
148+ foreach (explode(' ', $newName) as $word) {
149+ if (strlen($word) === 1) {
150+ $problems[] = t('acp.plr.err_word_length');
151+ break;
152+ }
153+ if (in_array(strtolower($word), $config['invalidNameTags'] ?? [], true)) {
154+ $problems[] = t('acp.plr.err_restricted_word');
155+ break;
156+ }
157+ }
158+ if (in_array(strtolower($newName), $config['creatureNameTags'] ?? [], true)) {
159+ $problems[] = t('acp.plr.err_creature_name');
160+ }
161+ }
162+
163+ if (is_array($character) && isset($character['online']) && (int)$character['online'] !== 0) {
164+ $problems[] = t('acp.plr.err_must_be_offline');
165+ }
166+
167+ if (!$problems && is_array($character)) {
168+ $characterId = (int)$character['id'];
169+ $duplicate = db()->fetchOne("
170+ SELECT `id` FROM `players`
171+ WHERE `name` = ?
172+ AND `id` <> ?
173+ LIMIT 1;
174+ ", [(string)$newName, $characterId]);
175+ if (is_array($duplicate)) {
176+ $problems[] = t('acp.plr.err_name_taken');
177+ }
178+ }
179+
180+ if ($problems) {
181+ acp_flash_error(implode(' ', array_map('h', $problems)));
182+ acp_redirect('players');
183+ }
184+
185+ $oldName = (string)$character['name'];
186+ $characterId = (int)$character['id'];
187+ if (strcasecmp($oldName, (string)$newName) === 0 && $oldName === $newName) {
188+ acp_flash_error(t('acp.plr.err_same_name'));
189+ acp_redirect('players');
190+ }
191+
192+ $db = db();
193+ if (!$db->beginTransaction()) {
194+ acp_flash_error(t('acp.plr.err_rename_failed'));
195+ acp_redirect('players');
196+ }
197+
198+ if (!$db->execute("
199+ UPDATE `players`
200+ SET `name` = ?
201+ WHERE `id` = ?
202+ LIMIT 1;
203+ ", [(string)$newName, $characterId])) {
204+ $db->rollback();
205+ acp_flash_error(t('acp.plr.err_rename_failed'));
206+ acp_redirect('players');
207+ }
208+
209+ foreach (['znote_forum_threads', 'znote_forum_posts'] as $forumTable) {
210+ if (acp_players_table_exists($forumTable)) {
211+ if (!$db->execute("
212+ UPDATE `{$forumTable}`
213+ SET `player_name` = ?
214+ WHERE `player_id` = ?;
215+ ", [(string)$newName, $characterId])) {
216+ $db->rollback();
217+ acp_flash_error(t('acp.plr.err_rename_failed'));
218+ acp_redirect('players');
219+ }
220+ }
221+ }
222+
223+ $db->commit();
224+
225+ znote_hook('character.renamed', [
226+ 'player_id' => $characterId,
227+ 'old_name' => $oldName,
228+ 'new_name' => $newName,
229+ ]);
230+ acp_log('player.rename', $oldName, ['new_name' => (string)$newName]);
231+ acp_flash_success(t('acp.plr.renamed', ['old' => h($oldName), 'new' => h((string)$newName)]));
232+ acp_redirect('players');
233+ }
234+
235+ // ---------------------------------------------------------- Give points
236+ if (!empty($_POST['points_char']) && !empty($_POST['points_value'])) {
237+ $char = trim((string)$_POST['points_char']);
238+ $points = intv($_POST['points_value']);
239+
240+ $acc = db()->fetchOne("
241+ SELECT `account_id` FROM `players`
242+ WHERE `name` = ?
243+ LIMIT 1;
244+ ", [$char]);
245+
246+ if (is_array($acc)) {
247+ $accountId = (int)$acc['account_id'];
248+
249+ $znote = db()->fetchOne("
250+ SELECT `points` FROM `znote_accounts`
251+ WHERE `account_id` = ?
252+ LIMIT 1;
253+ ", [$accountId]);
254+
255+ if (is_array($znote)) {
256+ $newPoints = intv($znote['points']) + $points;
257+
258+ db()->execute("
259+ UPDATE `znote_accounts`
260+ SET `points` = ?
261+ WHERE `account_id` = ?;
262+ ", [$newPoints, $accountId]);
263+
264+ acp_log('player.give_points', $char, ['points' => $points, 'new_balance' => $newPoints]);
265+ acp_flash_success(t('acp.plr.points_given', [
266+ 'points' => h((string)$points),
267+ 'char' => h($char),
268+ 'balance' => h((string)$newPoints),
269+ ]));
270+ } else {
271+ acp_flash_error(t('acp.plr.err_no_znote_row'));
272+ }
273+ } else {
274+ acp_flash_error(t('acp.plr.char_not_exist', ['char' => h($char)]));
275+ }
276+
277+ acp_redirect('players');
278+ }
279+
280+ // ------------------------------------------------------ Ingame position
281+ if (!empty($_POST['position_name']) && isset($_POST['position_type'])) {
282+ $char = trim((string)$_POST['position_name']);
283+ $pos = $_POST['position_type'];
284+
285+ if (user_character_exist($char) && isset($config['ingame_positions'][$pos])) {
286+ if ($isNotTfs03) {
287+ set_ingame_position($char, $pos);
288+ } else {
289+ set_ingame_position03($char, $pos);
290+ }
291+
292+ acp_log('player.set_position', $char, ['position' => $config['ingame_positions'][$pos]]);
293+ acp_flash_success(t('acp.plr.position_set', [
294+ 'char' => h($char),
295+ 'position' => h($config['ingame_positions'][$pos]),
296+ ]));
297+ } else {
298+ acp_flash_error(t('acp.plr.err_unknown_char_position'));
299+ }
300+
301+ acp_redirect('players');
302+ }
303+}
304+?>
305+
306+<div class="acp-grid acp-grid--2">
307+
308+ <!-- ---------------------------------------------------- Give points -->
309+ <section class="acp-card">
310+ <header class="acp-card-head">
311+ <h2><?= h(t('acp.plr.give_points_title')) ?></h2>
312+ <p><?= h(t('acp.plr.give_points_sub')) ?></p>
313+ </header>
314+ <div class="acp-card-body">
315+ <form method="post">
316+ <?= acp_csrf_field() ?>
317+ <div class="acp-row">
318+ <div class="acp-field">
319+ <label class="acp-label" for="points_char"><?= h(t('acp.plr.character_label')) ?></label>
320+ <input class="acp-input" id="points_char" name="points_char" placeholder="<?= h(t('acp.plr.character_name_placeholder')) ?>" required>
321+ </div>
322+ <div class="acp-field">
323+ <label class="acp-label" for="points_value"><?= h(t('acp.plr.points_label')) ?></label>
324+ <input class="acp-input" id="points_value" name="points_value" type="number" value="10" required>
325+ </div>
326+ </div>
327+ <p class="acp-hint"><?= h(t('acp.plr.points_hint')) ?></p>
328+ <div class="acp-actions">
329+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-diamond"></i> <?= h(t('acp.plr.give_points_btn')) ?></button>
330+ </div>
331+ </form>
332+ </div>
333+ </section>
334+
335+ <!-- ------------------------------------------------- Reset password -->
336+ <section class="acp-card">
337+ <header class="acp-card-head">
338+ <h2><?= h(t('acp.plr.reset_pass_title')) ?></h2>
339+ <p><?= h(t('acp.plr.reset_pass_sub')) ?></p>
340+ </header>
341+ <div class="acp-card-body">
342+ <form method="post">
343+ <?= acp_csrf_field() ?>
344+ <div class="acp-row">
345+ <div class="acp-field">
346+ <label class="acp-label" for="reset_pass"><?= h(t('acp.plr.character_label')) ?></label>
347+ <input class="acp-input" id="reset_pass" name="reset_pass" placeholder="<?= h(t('acp.plr.character_name_placeholder')) ?>" required>
348+ </div>
349+ <div class="acp-field">
350+ <label class="acp-label" for="new_pass"><?= h(t('acp.plr.new_password_label')) ?></label>
351+ <input class="acp-input" id="new_pass" name="new_pass" type="text" placeholder="<?= h(t('acp.plr.new_password_label')) ?>" required>
352+ </div>
353+ </div>
354+ <p class="acp-hint"><?= h(t('acp.plr.reset_pass_hint')) ?></p>
355+ <div class="acp-actions">
356+ <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-key"></i> <?= h(t('acp.plr.reset_pass_btn')) ?></button>
357+ </div>
358+ </form>
359+ </div>
360+ </section>
361+
362+ <!-- ------------------------------------------------ Rename character -->
363+ <section class="acp-card">
364+ <header class="acp-card-head">
365+ <h2><?= h(t('acp.plr.rename_title')) ?></h2>
366+ <p><?= h(t('acp.plr.rename_sub')) ?></p>
367+ </header>
368+ <div class="acp-card-body">
369+ <form method="post">
370+ <?= acp_csrf_field() ?>
371+ <input type="hidden" name="rename_character" value="1">
372+ <div class="acp-row">
373+ <div class="acp-field">
374+ <label class="acp-label" for="rename_current"><?= h(t('acp.plr.current_name_label')) ?></label>
375+ <input class="acp-input" id="rename_current" name="rename_current" placeholder="<?= h(t('acp.plr.current_name_placeholder')) ?>" required>
376+ </div>
377+ <div class="acp-field">
378+ <label class="acp-label" for="rename_new"><?= h(t('acp.plr.new_name_label')) ?></label>
379+ <input class="acp-input" id="rename_new" name="rename_new" placeholder="<?= h(t('acp.plr.new_name_placeholder')) ?>" required>
380+ </div>
381+ </div>
382+ <div class="acp-actions">
383+ <button class="acp-btn acp-btn--blue" type="submit"><i class="fa fa-pencil"></i> <?= h(t('acp.plr.rename_btn')) ?></button>
384+ </div>
385+ </form>
386+ </div>
387+ </section>
388+
389+ <!-- ------------------------------------------------ Ingame position -->
390+ <section class="acp-card">
391+ <header class="acp-card-head">
392+ <h2><?= h(t('acp.plr.position_title')) ?></h2>
393+ <p><?= h(t('acp.plr.position_sub')) ?></p>
394+ </header>
395+ <div class="acp-card-body">
396+ <form method="post">
397+ <?= acp_csrf_field() ?>
398+ <div class="acp-row">
399+ <div class="acp-field">
400+ <label class="acp-label" for="position_name"><?= h(t('acp.plr.character_label')) ?></label>
401+ <input class="acp-input" id="position_name" name="position_name" placeholder="<?= h(t('acp.plr.character_name_placeholder')) ?>" required>
402+ </div>
403+ <div class="acp-field">
404+ <label class="acp-label" for="position_type"><?= h(t('acp.plr.position_label')) ?></label>
405+ <select class="acp-select" id="position_type" name="position_type">
406+ <?php foreach (($config['ingame_positions'] ?? []) as $pid => $pname): ?>
407+ <option value="<?= h((string)$pid) ?>"><?= h($pname) ?></option>
408+ <?php endforeach; ?>
409+ </select>
410+ </div>
411+ </div>
412+ <div class="acp-actions">
413+ <button class="acp-btn acp-btn--blue" type="submit"><i class="fa fa-star"></i> <?= h(t('acp.plr.set_position_btn')) ?></button>
414+ </div>
415+ </form>
416+ </div>
417+ </section>
418+
419+ <!-- ------------------------------------------------ Delete character -->
420+ <section class="acp-card">
421+ <header class="acp-card-head">
422+ <h2><?= h(t('acp.plr.delete_title')) ?></h2>
423+ <p><?= h(t('acp.plr.delete_sub')) ?></p>
424+ </header>
425+ <div class="acp-card-body">
426+ <form method="post" data-confirm="<?= h(t('acp.plr.delete_confirm')) ?>">
427+ <?= acp_csrf_field() ?>
428+ <div class="acp-field">
429+ <label class="acp-label" for="del_name"><?= h(t('acp.plr.character_label')) ?></label>
430+ <input class="acp-input" id="del_name" name="del_name" placeholder="<?= h(t('acp.plr.character_name_placeholder')) ?>" required>
431+ </div>
432+ <div class="acp-actions">
433+ <button class="acp-btn acp-btn--red" type="submit"><i class="fa fa-trash"></i> <?= h(t('acp.plr.delete_btn')) ?></button>
434+ </div>
435+ </form>
436+ </div>
437+ </section>
438+</div>
439+
440+<!-- ------------------------------------------------------ Rule violation -->
441+<section class="acp-card">
442+ <header class="acp-card-head">
443+ <h2><?= h(t('acp.plr.violation_title')) ?></h2>
444+ <p><?= h(t('acp.plr.violation_sub')) ?></p>
445+ </header>
446+ <div class="acp-card-body">
447+ <form method="post" data-confirm="<?= h(t('acp.plr.violation_confirm')) ?>">
448+ <?= acp_csrf_field() ?>
449+ <div class="acp-row">
450+ <div class="acp-field">
451+ <label class="acp-label" for="ban_char"><?= h(t('acp.plr.character_label')) ?></label>
452+ <input class="acp-input" id="ban_char" name="ban_char" placeholder="<?= h(t('acp.plr.character_name_placeholder')) ?>" required>
453+ </div>
454+ <div class="acp-field">
455+ <label class="acp-label" for="ban_type"><?= h(t('acp.plr.type_label')) ?></label>
456+ <select class="acp-select" id="ban_type" name="ban_type">
457+ <?php foreach (($config['ban_type'] ?? []) as $id => $label): ?>
458+ <option value="<?= (int)$id + $enc ?>"><?= h($label) ?></option>
459+ <?php endforeach; ?>
460+ </select>
461+ </div>
462+ <div class="acp-field">
463+ <label class="acp-label" for="ban_action"><?= h(t('acp.plr.action_label')) ?></label>
464+ <select class="acp-select" id="ban_action" name="ban_action">
465+ <?php foreach (($config['ban_action'] ?? []) as $id => $label): ?>
466+ <option value="<?= (int)$id + $enc ?>"><?= h($label) ?></option>
467+ <?php endforeach; ?>
468+ </select>
469+ </div>
470+ </div>
471+
472+ <div class="acp-row">
473+ <div class="acp-field">
474+ <label class="acp-label" for="ban_reason"><?= h(t('acp.plr.reason_label')) ?></label>
475+ <select class="acp-select" id="ban_reason" name="ban_reason">
476+ <?php foreach (($config['ban_reason'] ?? []) as $id => $label): ?>
477+ <option value="<?= (int)$id + $enc ?>"><?= h($label) ?></option>
478+ <?php endforeach; ?>
479+ </select>
480+ </div>
481+ <div class="acp-field">
482+ <label class="acp-label" for="ban_comment"><?= h(t('acp.plr.comment_label')) ?></label>
483+ <input class="acp-input" id="ban_comment" name="ban_comment" maxlength="60" placeholder="<?= h(t('acp.plr.comment_placeholder')) ?>">
484+ </div>
485+ </div>
486+
487+ <div class="acp-row">
488+ <div class="acp-field">
489+ <label class="acp-label" for="ban_duration_value"><?= h(t('acp.plr.duration_label')) ?></label>
490+ <input class="acp-input" id="ban_duration_value" name="ban_duration_value" type="number" min="1" step="1" value="1">
491+ </div>
492+ <div class="acp-field">
493+ <label class="acp-label" for="ban_duration_unit">&nbsp;</label>
494+ <select class="acp-select" id="ban_duration_unit" name="ban_duration_unit">
495+ <option value="minutes"><?= h(t('acp.plr.unit_minutes')) ?></option>
496+ <option value="hours" selected><?= h(t('acp.plr.unit_hours')) ?></option>
497+ <option value="days"><?= h(t('acp.plr.unit_days')) ?></option>
498+ <option value="weeks"><?= h(t('acp.plr.unit_weeks')) ?></option>
499+ </select>
500+ </div>
501+ <div class="acp-field">
502+ <label class="acp-label" for="ban_forever">&nbsp;</label>
503+ <label style="display:flex;align-items:center;gap:8px;font-weight:400;min-height:34px;">
504+ <input type="checkbox" id="ban_forever" name="ban_forever" value="1">
505+ <span><?= h(t('acp.plr.forever_label')) ?></span>
506+ </label>
507+ </div>
508+ </div>
509+
510+ <div class="acp-actions">
511+ <button class="acp-btn acp-btn--red" type="submit"><i class="fa fa-gavel"></i> <?= h(t('acp.plr.apply_violation_btn')) ?></button>
512+ </div>
513+ </form>
514+ </div>
515+</section>
516+
517+<script>
518+(function () {
519+ var forever = document.getElementById('ban_forever');
520+ var value = document.getElementById('ban_duration_value');
521+ var unit = document.getElementById('ban_duration_unit');
522+ if (!forever || !value || !unit) return;
523+
524+ forever.addEventListener('change', function () {
525+ value.disabled = forever.checked;
526+ unit.disabled = forever.checked;
527+ });
528+})();
529+</script>
A admin/modules/plugins.php +370-0 View file
@@ -0,0 +1,370 @@
1+<?php
2+/**
3+ * Title: Plugins
4+ * Icon: fa-plug
5+ * Group: Settings
6+ * Order: 20
7+ * Description: Install, update, enable and disable what is in plugins/.
8+ */
9+
10+/*
11+ * How a plugin gets here: you download it and drop the folder into plugins/.
12+ * ZnoteX does not fetch anything by itself - installing a plugin means running
13+ * someone else's PHP on every page of your site, so putting the file there
14+ * stays a deliberate act, not a button.
15+ *
16+ * From that point this page does the rest:
17+ *
18+ * Install runs install.sql and records the version (folder -> working)
19+ * Update shown when the folder is newer than what was installed
20+ * Enable the plugin starts running
21+ * Disable it stops, tables untouched
22+ */
23+
24+if (!defined('ACP_ROOT')) {
25+ http_response_code(403);
26+ die('Direct access denied.');
27+}
28+
29+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['clear_plugin_repository_cache'])) {
30+ if (plugin_repository_clear_cache()) {
31+ acp_log('plugins.cache_clear');
32+ acp_flash_success(t('acp.plgbr.cache_deleted'));
33+ } else {
34+ acp_flash_error(t('acp.plgbr.cache_delete_failed', [
35+ 'path' => '<code>' . h(plugin_repository_cache_path()) . '</code>',
36+ ]));
37+ }
38+
39+ acp_redirect('plugins', array('tab' => 'browse', 'refresh' => 1));
40+}
41+
42+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['repo_install'])) {
43+
44+ $key = znote_plugin_sanitize((string)$_POST['repo_install']);
45+ $overwrite = !empty($_POST['overwrite']);
46+ $result = plugin_repository_install($key, $overwrite);
47+
48+ if ($result === '') {
49+ $sqlError = znote_plugin_install($key);
50+ if ($sqlError !== '') {
51+ acp_flash_error(t('acp.plgbr.install_failed', ['error' => h($sqlError)]));
52+ } else {
53+ znote_plugin_set_enabled($key, true);
54+ acp_log('plugins.repo_install', $key);
55+ $m = znote_plugin_manifest($key);
56+ acp_flash_success(t('acp.plgbr.installed', [
57+ 'plugin' => '<strong>' . h($m['name']) . '</strong>',
58+ 'path' => '<code>plugins/' . h($key) . '/</code>',
59+ ]));
60+ }
61+ } elseif ($result === 'already-installed') {
62+ acp_flash_error(t('acp.plgbr.already_installed', ['plugin' => '<strong>' . h($key) . '</strong>']));
63+ } else {
64+ acp_flash_error(t('acp.plgbr.install_failed', ['error' => h($result)]));
65+ }
66+
67+ acp_redirect('plugins', array('tab' => 'browse'));
68+}
69+
70+if (($_GET['tab'] ?? '') === 'browse') {
71+ // "Refresh catalogue" also wipes the local caches - a stale cache is what
72+ // usually keeps a just-installed or updated plugin from showing correctly.
73+ if (isset($_GET['refresh']) && function_exists('znote_cache_flush')) {
74+ znote_cache_flush();
75+ acp_log('plugins.catalogue_refresh');
76+ }
77+ include ACP_ROOT . '/modules/_partials/plugins_browse.php';
78+ return;
79+}
80+
81+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
82+ $name = znote_plugin_sanitize((string)($_POST['plugin'] ?? ''));
83+ $action = (string)($_POST['action'] ?? '');
84+ $known = znote_plugins(true);
85+
86+ if ($name === '' || !isset($known[$name])) {
87+ acp_flash_error(t('acp.plg.no_such'));
88+ acp_redirect('plugins');
89+ }
90+
91+ $plugin = $known[$name];
92+ $label = $plugin['name'];
93+
94+ switch ($action) {
95+
96+ case 'install':
97+ $error = znote_plugin_install($name);
98+
99+ if ($error !== '') {
100+ acp_flash_error(t('acp.plg.install_failed', ['plugin' => $label, 'error' => $error]));
101+ break;
102+ }
103+
104+ // Installing implies wanting it on. Nobody installs a plugin in
105+ // order to leave it switched off.
106+ znote_plugin_set_enabled($name, true);
107+ acp_log('plugin.install', $name, ['version' => $plugin['version']]);
108+ acp_flash_success(t('acp.plg.installed', ['plugin' => $label, 'version' => $plugin['version']]));
109+ break;
110+
111+ case 'update':
112+ $from = $plugin['installed_version'];
113+ $error = znote_plugin_install($name);
114+
115+ if ($error !== '') {
116+ acp_flash_error(t('acp.plg.update_failed', ['plugin' => $label, 'error' => $error]));
117+ } else {
118+ acp_log('plugin.update', $name, ['from' => $from, 'to' => $plugin['version']]);
119+ acp_flash_success(t('acp.plg.updated', ['plugin' => $label, 'from' => $from, 'to' => $plugin['version']]));
120+ }
121+ break;
122+
123+ case 'enable':
124+ if (!$plugin['compatible']) {
125+ acp_flash_error(h(implode(' ', $plugin['compatibility_errors'])));
126+ } elseif (znote_plugin_set_enabled($name, true)) {
127+ acp_log('plugin.enable', $name);
128+ acp_flash_success(t('acp.plg.enabled', ['plugin' => $label]));
129+ } else {
130+ acp_flash_error(t_default('acp.plg.enable_failed', 'Could not enable {plugin}.', ['plugin' => h($label)]));
131+ }
132+ break;
133+
134+ case 'disable':
135+ znote_plugin_set_enabled($name, false);
136+ acp_log('plugin.disable', $name);
137+ acp_flash_info(t('acp.plg.disabled', ['plugin' => $label]));
138+ break;
139+
140+ case 'uninstall':
141+ // Forgets the install, keeps the data. Dropping a player's coupons
142+ // because someone clicked a button would be the wrong default.
143+ znote_plugin_uninstall($name);
144+ acp_log('plugin.uninstall', $name);
145+ acp_flash_info(t('acp.plg.uninstalled', ['plugin' => $label]));
146+ break;
147+
148+ default:
149+ acp_flash_error(t('acp.plg.unknown_action'));
150+ }
151+
152+ acp_redirect('plugins');
153+}
154+
155+$plugins = znote_plugins(true);
156+$active = 0;
157+$updatable = 0;
158+
159+foreach ($plugins as $plugin) {
160+ if ($plugin['enabled'] && $plugin['installed'] && $plugin['compatible']) {
161+ $active++;
162+ }
163+ if ($plugin['update']) {
164+ $updatable++;
165+ }
166+}
167+?>
168+
169+<?php if (plugin_repository_config()['enabled']): ?>
170+ <div class="acp-toolbar">
171+ <div></div>
172+ <div class="acp-actions is-tight">
173+ <a class="acp-btn" href="<?= h(acp_url('plugins', array('tab' => 'browse'))) ?>">
174+ <i class="fa fa-cloud-download"></i> <?= t('acp.plg.browse_plugins') ?>
175+ </a>
176+ </div>
177+ </div>
178+<?php endif; ?>
179+
180+<div class="acp-grid">
181+ <?php
182+ acp_stat(t('acp.plg.stat_in_folder'), count($plugins), 'fa-folder-o', null, 'blue');
183+ acp_stat(t('acp.plg.stat_running'), $active, 'fa-check', null, 'green');
184+ if ($updatable > 0) {
185+ acp_stat(t('acp.plg.stat_updates'), $updatable, 'fa-arrow-up', null, 'amber');
186+ }
187+ ?>
188+</div>
189+
190+<?php acp_card_open(t('acp.plg.title'), t('acp.plg.sub')); ?>
191+
192+ <?php if (!$plugins): ?>
193+
194+ <?php acp_empty(t('acp.plg.empty'), 'fa-plug'); ?>
195+
196+ <?php else: ?>
197+
198+ <?php if (count($plugins) > 6): ?>
199+ <div class="acp-toolbar">
200+ <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
201+ <input class="acp-input" type="search" data-acp-search-input="acpPluginTable"
202+ placeholder="<?= h(t_default('acp.plg.search_placeholder', 'Search plugins...')) ?>">
203+ </div>
204+ <span class="is-muted" data-acp-search-count="acpPluginTable"></span>
205+ </div>
206+ <?php endif; ?>
207+
208+ <table class="acp-table" id="acpPluginTable">
209+ <thead>
210+ <tr>
211+ <th><?= t('acp.plg.col_plugin') ?></th>
212+ <th><?= t('acp.plg.col_adds') ?></th>
213+ <th><?= t('acp.plg.col_version') ?></th>
214+ <th><?= t('acp.plg.col_status') ?></th>
215+ <th></th>
216+ </tr>
217+ </thead>
218+ <tbody>
219+ <?php foreach ($plugins as $name => $plugin):
220+
221+ $adds = array();
222+ if ($plugin['pages']) { $adds[] = t('acp.plg.page_count', ['n' => $plugin['pages']]); }
223+ if ($plugin['admin']) { $adds[] = t('acp.plg.admin_page_count', ['n' => $plugin['admin']]); }
224+ if ($plugin['sql']) { $adds[] = t('acp.plg.tables'); }
225+
226+ $running = $plugin['installed'] && $plugin['enabled'] && $plugin['compatible'];
227+ ?>
228+ <tr data-acp-search="<?= h(strtolower($plugin['name'] . ' ' . $name . ' ' . ($plugin['description'] ?? ''))) ?>">
229+ <td>
230+ <strong><?= h($plugin['name']) ?></strong>
231+ <?php if ($plugin['description'] !== ''): ?>
232+ <span class="acp-hint" style="display:block;"><?= h($plugin['description']) ?></span>
233+ <?php endif; ?>
234+ <span class="acp-hint" style="display:block;">
235+ <code><?= h($name) ?></code>
236+ <?php if ($plugin['author'] !== ''): ?>&middot; <?= h($plugin['author']) ?><?php endif; ?>
237+ <?php if ($plugin['url'] !== ''): ?>
238+ &middot; <a href="<?= h($plugin['url']) ?>" target="_blank" rel="noopener noreferrer"><?= t('acp.plg.website') ?></a>
239+ <?php endif; ?>
240+ </span>
241+ <?php if (!$plugin['compatible']): ?>
242+ <span class="acp-hint" style="display:block;color:var(--acp-red);">
243+ <?= h(implode(' ', $plugin['compatibility_errors'])) ?>
244+ </span>
245+ <?php endif; ?>
246+ </td>
247+
248+ <td><?= $adds ? h(implode(', ', $adds)) : '<span class="acp-hint">' . t('acp.plg.hooks_only') . '</span>' ?></td>
249+
250+ <td>
251+ <?= $plugin['version'] !== '' ? h($plugin['version']) : '&mdash;' ?>
252+ <?php if (!$plugin['compatible']): ?>
253+ <span class="acp-pill acp-pill--red"><?= h(t_default('acp.plg.incompatible', 'Incompatible')) ?></span>
254+ <?php elseif ($plugin['update']): ?>
255+ <span class="acp-hint" style="display:block;"><?= t('acp.plg.installed_label', ['version' => h($plugin['installed_version'])]) ?></span>
256+ <?php endif; ?>
257+ </td>
258+
259+ <td>
260+ <?php if ($plugin['update']): ?>
261+ <span class="acp-pill acp-pill--amber"><?= t('acp.plg.update_available') ?></span>
262+ <?php elseif (!$plugin['installed']): ?>
263+ <span class="acp-pill"><?= t('acp.plg.not_installed') ?></span>
264+ <?php elseif ($running): ?>
265+ <span class="acp-pill acp-pill--green"><?= t('acp.plg.running_pill') ?></span>
266+ <?php else: ?>
267+ <span class="acp-pill"><?= t('acp.plg.disabled_pill') ?></span>
268+ <?php endif; ?>
269+ </td>
270+
271+ <td style="text-align:right;white-space:nowrap;">
272+
273+ <?php if ($running && $plugin['pages']): ?>
274+ <a class="acp-btn acp-btn--ghost acp-btn--sm"
275+ href="<?= h(acp_site(znote_plugin_url($name, $plugin['page_list'][0]))) ?>"
276+ target="_blank" rel="noopener"><?= t('acp.plg.view') ?></a>
277+ <?php endif; ?>
278+
279+ <?php if ($running && znote_plugin_settings_has($name)): ?>
280+ <a class="acp-btn acp-btn--ghost acp-btn--sm"
281+ href="<?= h(acp_url('plugin_settings', array('plugin' => $name))) ?>">
282+ <i class="fa fa-sliders"></i> <?= t_default('acp.plg.settings', 'Settings') ?>
283+ </a>
284+ <?php endif; ?>
285+
286+ <?php if (!$plugin['installed']): ?>
287+
288+ <form method="post" style="display:inline;">
289+ <?= acp_csrf_field() ?>
290+ <input type="hidden" name="plugin" value="<?= h($name) ?>">
291+ <input type="hidden" name="action" value="install">
292+ <button class="acp-btn acp-btn--green acp-btn--sm" type="submit" <?= $plugin['compatible'] ? '' : 'disabled' ?>>
293+ <i class="fa fa-download"></i> <?= t('acp.plg.install') ?>
294+ </button>
295+ </form>
296+
297+ <?php else: ?>
298+
299+ <?php if ($plugin['update']): ?>
300+ <form method="post" style="display:inline;">
301+ <?= acp_csrf_field() ?>
302+ <input type="hidden" name="plugin" value="<?= h($name) ?>">
303+ <input type="hidden" name="action" value="update">
304+ <button class="acp-btn acp-btn--amber acp-btn--sm" type="submit" <?= $plugin['compatible'] ? '' : 'disabled' ?>>
305+ <i class="fa fa-arrow-up"></i> <?= t('acp.plg.update_to', ['version' => h($plugin['version'])]) ?>
306+ </button>
307+ </form>
308+ <?php endif; ?>
309+
310+ <form method="post" style="display:inline;">
311+ <?= acp_csrf_field() ?>
312+ <input type="hidden" name="plugin" value="<?= h($name) ?>">
313+ <input type="hidden" name="action" value="<?= $plugin['enabled'] ? 'disable' : 'enable' ?>">
314+ <button class="acp-btn acp-btn--sm <?= $plugin['enabled'] ? '' : 'acp-btn--green' ?>" type="submit" <?= !$plugin['enabled'] && !$plugin['compatible'] ? 'disabled' : '' ?>>
315+ <?= $plugin['enabled'] ? t('acp.plg.disable') : t('acp.plg.enable') ?>
316+ </button>
317+ </form>
318+
319+ <form method="post" style="display:inline;"
320+ onsubmit="return confirm('<?= h(t('acp.plg.confirm_uninstall', ['plugin' => $plugin['name']])) ?>');">
321+ <?= acp_csrf_field() ?>
322+ <input type="hidden" name="plugin" value="<?= h($name) ?>">
323+ <input type="hidden" name="action" value="uninstall">
324+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit" title="<?= h(t('acp.plg.uninstall')) ?>">
325+ <i class="fa fa-times"></i>
326+ </button>
327+ </form>
328+
329+ <?php endif; ?>
330+
331+ </td>
332+ </tr>
333+ <?php endforeach; ?>
334+ </tbody>
335+ </table>
336+
337+ <?php endif; ?>
338+
339+<?php acp_card_close(); ?>
340+
341+<?php acp_card_open(t('acp.plg.how_title'), ''); ?>
342+ <p>
343+ <strong><?= t('acp.plg.how_install') ?></strong>
344+ <?= t('acp.plg.how_install_text', [
345+ 'folder' => '<code>plugins/</code>',
346+ 'install' => '<em>' . t('acp.plg.install') . '</em>',
347+ ]) ?>
348+ </p>
349+ <p>
350+ <strong><?= t('acp.plg.how_update') ?></strong>
351+ <?= t('acp.plg.how_update_text', [
352+ 'json' => '<code>plugin.json</code>',
353+ 'update' => '<em>Update</em>',
354+ ]) ?>
355+ </p>
356+ <p>
357+ <strong><?= t('acp.plg.how_remove') ?></strong>
358+ <?= t('acp.plg.how_remove_text', [
359+ 'disable' => '<em>' . t('acp.plg.disable') . '</em>',
360+ 'uninstall' => '<em>' . t('acp.plg.uninstall') . '</em>',
361+ ]) ?>
362+ </p>
363+ <p>
364+ <?= t('acp.plg.how_write', [
365+ 'json' => '<code>plugin.json</code>',
366+ 'readme' => '<code>plugins/README.md</code>',
367+ 'example' => '<code>plugins/shop_coupons/</code>',
368+ ]) ?>
369+ </p>
370+<?php acp_card_close(); ?>
A admin/modules/plugin_settings.php +128-0 View file
@@ -0,0 +1,128 @@
1+<?php
2+/**
3+ * Title: Plugin settings
4+ * Icon: fa-sliders
5+ * Group: Settings
6+ * Order: 21
7+ * Description: Auto-generated configuration page for a plugin's settings.json.
8+ * Hidden: true
9+ */
10+
11+/*
12+ * Not linked from the sidebar - reached from the "Settings" button on a
13+ * plugin's row in Admin Panel > Plugins. A plugin gets this page for free by
14+ * shipping plugins/<name>/settings.json instead of hand-coding a form; see
15+ * engine/function/plugin_settings.php for the schema.
16+ */
17+
18+if (!defined('ACP_ROOT')) {
19+ http_response_code(403);
20+ die('Direct access denied.');
21+}
22+
23+$plugin = znote_plugin_sanitize((string)($_GET['plugin'] ?? $_POST['plugin'] ?? ''));
24+$known = znote_plugins();
25+
26+if ($plugin === '' || !isset($known[$plugin]) || !znote_plugin_settings_has($plugin)) {
27+ acp_flash_error(t_default('acp.plgset.no_such', 'That plugin has no settings.json.'));
28+ acp_redirect('plugins');
29+}
30+
31+$manifest = $known[$plugin];
32+$schema = znote_plugin_settings_schema($plugin);
33+
34+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
35+ if (!acp_verify_csrf()) {
36+ acp_flash_error(t_default('acp.csrf_failed', 'Your session expired, please try again.'));
37+ acp_redirect('plugin_settings', array('plugin' => $plugin));
38+ }
39+
40+ $errors = znote_plugin_settings_save($plugin, $_POST);
41+
42+ if ($errors) {
43+ acp_flash_error(t_default('acp.plgset.save_failed', 'Some fields were not valid and kept their previous value: {fields}.', ['fields' => implode(', ', $errors)]));
44+ } else {
45+ acp_log('plugin.settings_save', $plugin);
46+ acp_flash_success(t_default('acp.plgset.saved', 'Settings saved.'));
47+ }
48+
49+ acp_redirect('plugin_settings', array('plugin' => $plugin));
50+}
51+
52+$values = znote_plugin_settings_get($plugin);
53+?>
54+
55+<?php acp_card_open(t_default('acp.plgset.title', '{plugin} settings', ['plugin' => h($manifest['name'])]), h($manifest['description'])); ?>
56+
57+ <form method="post">
58+ <?= acp_csrf_field() ?>
59+ <input type="hidden" name="plugin" value="<?= h($plugin) ?>">
60+
61+ <?php if (!$schema): ?>
62+ <?php acp_empty(t_default('acp.plgset.empty', 'settings.json has no valid fields.'), 'fa-sliders'); ?>
63+ <?php endif; ?>
64+
65+ <?php foreach ($schema as $key => $field):
66+ $value = $values[$key] ?? $field['default'];
67+ $id = 'plgset_' . $key;
68+ ?>
69+ <div class="acp-field">
70+ <label for="<?= h($id) ?>"><?= h($field['label']) ?></label>
71+
72+ <?php if ($field['type'] === 'bool'): ?>
73+ <label class="acp-inline">
74+ <input type="checkbox" id="<?= h($id) ?>" name="<?= h($key) ?>" value="1" <?= $value === '1' ? 'checked' : '' ?>>
75+ </label>
76+
77+ <?php elseif ($field['type'] === 'textarea'): ?>
78+ <textarea id="<?= h($id) ?>" name="<?= h($key) ?>" rows="4" class="acp-input"><?= h($value) ?></textarea>
79+
80+ <?php elseif ($field['type'] === 'password'): ?>
81+ <input type="password" id="<?= h($id) ?>" name="<?= h($key) ?>" value="<?= h($value) ?>" class="acp-input" autocomplete="off">
82+
83+ <?php elseif ($field['type'] === 'int'): ?>
84+ <input type="number" id="<?= h($id) ?>" name="<?= h($key) ?>" value="<?= h($value) ?>" class="acp-input"
85+ <?= $field['min'] !== null ? 'min="' . (int)$field['min'] . '"' : '' ?>
86+ <?= $field['max'] !== null ? 'max="' . (int)$field['max'] . '"' : '' ?>>
87+
88+ <?php elseif ($field['type'] === 'select'): ?>
89+ <select id="<?= h($id) ?>" name="<?= h($key) ?>" class="acp-input">
90+ <?php foreach ($field['options'] as $optValue => $optLabel): ?>
91+ <option value="<?= h($optValue) ?>" <?= $value === $optValue ? 'selected' : '' ?>><?= h($optLabel) ?></option>
92+ <?php endforeach; ?>
93+ </select>
94+
95+ <?php elseif ($field['type'] === 'color'): ?>
96+ <div class="acp-inline" style="gap:8px;">
97+ <input type="color" value="<?= h($value !== '' ? $value : '#000000') ?>"
98+ onchange="document.getElementById('<?= h($id) ?>').value=this.value;">
99+ <input type="text" id="<?= h($id) ?>" name="<?= h($key) ?>" value="<?= h($value) ?>" class="acp-input"
100+ placeholder="<?= h(t_default('acp.plgset.color_placeholder', 'blank = theme default')) ?>" maxlength="7" style="max-width:110px;">
101+ </div>
102+
103+ <?php elseif ($field['type'] === 'checklist'):
104+ $chosen = array_flip(array_filter(explode(',', $value)));
105+ ?>
106+ <?php foreach ($field['options'] as $optValue => $optLabel): ?>
107+ <label class="acp-inline">
108+ <input type="checkbox" name="<?= h($key) ?>[]" value="<?= h($optValue) ?>" <?= isset($chosen[$optValue]) ? 'checked' : '' ?>>
109+ <?= h($optLabel) ?>
110+ </label>
111+ <?php endforeach; ?>
112+
113+ <?php else: ?>
114+ <input type="text" id="<?= h($id) ?>" name="<?= h($key) ?>" value="<?= h($value) ?>" class="acp-input">
115+ <?php endif; ?>
116+
117+ <?php if ($field['help'] !== ''): ?>
118+ <p class="acp-hint"><?= h($field['help']) ?></p>
119+ <?php endif; ?>
120+ </div>
121+ <?php endforeach; ?>
122+
123+ <?php if ($schema): ?>
124+ <button type="submit" class="acp-btn acp-btn--green"><?= t_default('acp.plgset.save', 'Save') ?></button>
125+ <?php endif; ?>
126+ </form>
127+
128+<?php acp_card_close(); ?>
A admin/modules/reports.php +356-0 View file
@@ -0,0 +1,356 @@
1+<?php
2+/**
3+ * Title: Bug Reports
4+ * Icon: fa-bug
5+ * Group: Support
6+ * Order: 10
7+ * Description: Triage in-game reports, reward reporters and publish changelogs.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$statusTypes = [
16+ 0 => t('acp.rep.status_reported'),
17+ 1 => t('acp.rep.status_todo'),
18+ 2 => t('acp.rep.status_confirmed'),
19+ 3 => t('acp.rep.status_invalid'),
20+ 4 => t('acp.rep.status_rejected'),
21+ 5 => t('acp.rep.status_fixed'),
22+];
23+
24+$statusTone = [
25+ 0 => 'purple',
26+ 1 => 'blue',
27+ 2 => 'red',
28+ 3 => 'grey',
29+ 4 => 'grey',
30+ 5 => 'green',
31+];
32+
33+// Statuses that may carry a public changelog entry.
34+$statusChangeLog = [0, 5];
35+
36+// Statuses whose section starts collapsed.
37+$collapsedStatus = [3, 4, 5];
38+
39+// ---------------------------------------------------------------------------
40+// Update a report
41+// ---------------------------------------------------------------------------
42+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
43+
44+ $playerName = trim((string)($_POST['playerName'] ?? ''));
45+ $status = intv($_POST['status'] ?? 0);
46+ $reportId = intv($_POST['id'] ?? 0);
47+ $price = intv($_POST['price'] ?? 0) + intv($_POST['customPoints'] ?? 0);
48+ if ($price > 0 && !is_admin($user_data ?? null)) {
49+ $price = 0;
50+ acp_log('access.action_denied', 'reports.reward', ['roles' => admin_roles($user_data ?? null)]);
51+ acp_flash_error('Only an owner can grant reward points. The report status was still processed.');
52+ }
53+
54+ if ($reportId <= 0 || !isset($statusTypes[$status])) {
55+ acp_flash_error(t('acp.rep.invalid'));
56+ acp_redirect('reports');
57+ }
58+
59+ db()->execute("
60+ UPDATE `znote_player_reports`
61+ SET `status` = ?
62+ WHERE `id` = ?
63+ LIMIT 1;
64+ ", [$status, $reportId]);
65+ acp_log('reports.status', '#' . $reportId, ['status' => $statusTypes[$status]]);
66+ acp_flash_success(t('acp.rep.set_to', ['id' => $reportId, 'status' => '<strong>' . h($statusTypes[$status]) . '</strong>']));
67+
68+ // ------------------------------------------------------ Changelog entry
69+ $changelogReportId = intv($_POST['changelogReportId'] ?? 0);
70+ $changelogValue = (string)($_POST['changelogValue'] ?? '1');
71+ $changelogText = trim((string)($_POST['changelogText'] ?? ''));
72+
73+ if ($changelogReportId > 0 && $changelogValue === '2' && $changelogText !== '') {
74+ $now = time();
75+
76+ $existing = db()->fetchOne("
77+ SELECT `id` FROM `znote_changelog`
78+ WHERE `report_id` = ?
79+ LIMIT 1;
80+ ", [$changelogReportId]);
81+
82+ if (is_array($existing)) {
83+ db()->execute("
84+ UPDATE `znote_changelog`
85+ SET `text` = ?, `time` = ?
86+ WHERE `id` = ?
87+ LIMIT 1;
88+ ", [$changelogText, $now, (int)$existing['id']]);
89+ acp_log('reports.changelog_update', '#' . $changelogReportId, ['changelog_id' => (int)$existing['id']]);
90+ acp_flash_info(t('acp.rep.changelog_updated'));
91+ } else {
92+ db()->execute("
93+ INSERT INTO `znote_changelog` (`text`, `time`, `report_id`, `status`)
94+ VALUES (?, ?, ?, ?);
95+ ", [$changelogText, $now, $changelogReportId, $status]);
96+ acp_log('reports.changelog_create', '#' . $changelogReportId);
97+ acp_flash_info(t('acp.rep.changelog_created'));
98+ }
99+
100+ $cache = new Cache('engine/cache/changelog');
101+ $cache->setContent(db()->fetchAll("
102+ SELECT `id`, `text`, `time`, `report_id`, `status`
103+ FROM `znote_changelog`
104+ ORDER BY `id` DESC;
105+ ") ?: []);
106+ $cache->save();
107+ }
108+
109+ // ------------------------------------------------------- Reward points
110+ if ($price > 0 && $playerName !== '') {
111+ $account = db()->fetchOne("
112+ SELECT `a`.`id`, `a`.`email`
113+ FROM `accounts` `a`
114+ INNER JOIN `players` `p` ON `p`.`account_id` = `a`.`id`
115+ WHERE `p`.`name` = ?
116+ LIMIT 1;
117+ ", [$playerName]);
118+
119+ if (is_array($account)) {
120+ $accountId = (int)$account['id'];
121+
122+ $rewarded = db()->transaction(function ($db) use ($reportId, $accountId, $price, $account, $user_data) {
123+ $balance = $db->fetchOne(
124+ "SELECT `points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1 FOR UPDATE;",
125+ [$accountId]
126+ );
127+ if (!is_array($balance)) {
128+ return false;
129+ }
130+
131+ $db->execute(
132+ "INSERT INTO `znote_paypal` VALUES ('', ?, ?, ?, 0, ?);",
133+ [
134+ $reportId,
135+ 'report@admin' . (string)($user_data['name'] ?? '') . ' to ' . (string)$account['email'],
136+ $accountId,
137+ $price,
138+ ]
139+ );
140+
141+ $newPoints = ((int)$balance['points']) + $price;
142+ $db->execute(
143+ "UPDATE `znote_accounts` SET `points` = ? WHERE `account_id` = ?;",
144+ [$newPoints, $accountId]
145+ );
146+
147+ return true;
148+ });
149+
150+ if ($rewarded) {
151+ acp_log('reports.reward', $playerName, ['points' => $price, 'report_id' => $reportId]);
152+ acp_flash_success(t('acp.rep.points_received', ['name' => h($playerName), 'price' => (int)$price]));
153+ } else {
154+ acp_flash_error(t('acp.rep.no_account_row'));
155+ }
156+ } else {
157+ acp_flash_error(t('acp.rep.no_account_found', ['name' => '<strong>' . h($playerName) . '</strong>']));
158+ }
159+ }
160+
161+ acp_redirect('reports');
162+}
163+
164+// ---------------------------------------------------------------------------
165+// Load and group
166+// ---------------------------------------------------------------------------
167+$rows = db()->fetchAll("
168+ SELECT `id`, `name`, `posx`, `posy`, `posz`, `report_description`, `date`, `status`
169+ FROM `znote_player_reports`
170+ ORDER BY `id` DESC;
171+");
172+
173+$reports = [];
174+$total = 0;
175+if (is_array($rows)) {
176+ foreach ($rows as $r) {
177+ $reports[(int)$r['status']][(int)$r['id']] = $r;
178+ $total++;
179+ }
180+}
181+ksort($reports);
182+
183+// Report being edited
184+$editing = null;
185+if (($_GET['action'] ?? '') === 'edit') {
186+ $editId = intv($_GET['id'] ?? 0);
187+ foreach ($reports as $group) {
188+ if (isset($group[$editId])) {
189+ $editing = $group[$editId];
190+ break;
191+ }
192+ }
193+ if ($editing === null) {
194+ acp_flash_error(t('acp.rep.not_found'));
195+ acp_redirect('reports');
196+ }
197+}
198+?>
199+
200+<?php if ($editing !== null): ?>
201+
202+ <div class="acp-toolbar">
203+ <div>
204+ <strong><?= t('acp.rep.report_hash', ['id' => (int)$editing['id']]) ?></strong>
205+ <span class="acp-pill acp-pill--<?= h($statusTone[(int)$editing['status']] ?? 'grey') ?>">
206+ <?= h($statusTypes[(int)$editing['status']] ?? t('acp.rep.status_unknown')) ?>
207+ </span>
208+ </div>
209+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('reports')) ?>">
210+ <i class="fa fa-arrow-left"></i> <?= t('acp.rep.back_all') ?>
211+ </a>
212+ </div>
213+
214+ <div class="acp-grid acp-grid--2">
215+ <section class="acp-card">
216+ <header class="acp-card-head"><h2><?= t('acp.rep.the_report') ?></h2></header>
217+ <div class="acp-card-body">
218+ <dl class="acp-dl">
219+ <dt><?= t('acp.rep.reporter') ?></dt>
220+ <dd>
221+ <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$editing['name']))) ?>" target="_blank" rel="noopener">
222+ <?= h((string)$editing['name']) ?>
223+ </a>
224+ </dd>
225+ <dt><?= t('acp.rep.position') ?></dt>
226+ <dd><code>/pos <?= (int)$editing['posx'] ?>, <?= (int)$editing['posy'] ?>, <?= (int)$editing['posz'] ?></code></dd>
227+ <dt><?= t('acp.rep.reported') ?></dt>
228+ <dd><?= h(getClock((int)$editing['date'], true, true)) ?></dd>
229+ </dl>
230+ <hr>
231+ <p><?= nl2br(h((string)$editing['report_description'])) ?></p>
232+ </div>
233+ </section>
234+
235+ <section class="acp-card">
236+ <header class="acp-card-head"><h2><?= t('acp.rep.resolve') ?></h2></header>
237+ <div class="acp-card-body">
238+ <form method="post">
239+ <?= acp_csrf_field() ?>
240+ <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
241+ <input type="hidden" name="playerName" value="<?= h((string)$editing['name']) ?>">
242+
243+ <div class="acp-field">
244+ <label class="acp-label" for="status"><?= t('acp.rep.status') ?></label>
245+ <select class="acp-select" id="status" name="status">
246+ <?php foreach ($statusTypes as $sid => $label): ?>
247+ <option value="<?= (int)$sid ?>" <?= (int)$sid === (int)$editing['status'] ? 'selected' : '' ?>>
248+ <?= h($label) ?>
249+ </option>
250+ <?php endforeach; ?>
251+ </select>
252+ </div>
253+
254+ <div class="acp-row">
255+ <div class="acp-field">
256+ <label class="acp-label" for="price"><?= t('acp.rep.reward_preset') ?></label>
257+ <select class="acp-select" id="price" name="price">
258+ <option value="0"><?= t('acp.rep.no_points') ?></option>
259+ <?php foreach (($config['paypal_prices'] ?? []) as $p): ?>
260+ <option value="<?= (int)$p ?>"><?= t('acp.rep.n_points', ['n' => (int)$p]) ?></option>
261+ <?php endforeach; ?>
262+ </select>
263+ </div>
264+ <div class="acp-field">
265+ <label class="acp-label" for="customPoints"><?= t('acp.rep.extra_points') ?></label>
266+ <input class="acp-input" id="customPoints" name="customPoints" type="number" value="0">
267+ </div>
268+ </div>
269+
270+ <?php if (in_array((int)$editing['status'], $statusChangeLog, true)): ?>
271+ <hr>
272+ <input type="hidden" name="changelogReportId" value="<?= (int)$editing['id'] ?>">
273+ <div class="acp-field">
274+ <label class="acp-label" for="changelogValue"><?= t('acp.rep.publish_changelog') ?></label>
275+ <select class="acp-select" id="changelogValue" name="changelogValue">
276+ <option value="1"><?= t('acp.rep.no') ?></option>
277+ <option value="2"><?= t('acp.rep.yes') ?></option>
278+ </select>
279+ </div>
280+ <div class="acp-field">
281+ <label class="acp-label" for="changelogText"><?= t('acp.rep.changelog_text') ?></label>
282+ <textarea class="acp-textarea" id="changelogText" name="changelogText" rows="5"></textarea>
283+ <p class="acp-hint"><?= t('acp.rep.changelog_hint') ?></p>
284+ </div>
285+ <?php endif; ?>
286+
287+ <div class="acp-actions">
288+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= t('acp.rep.update_report') ?></button>
289+ </div>
290+ </form>
291+ </div>
292+ </section>
293+ </div>
294+
295+<?php elseif ($total === 0): ?>
296+
297+ <section class="acp-card">
298+ <div class="acp-card-body">
299+ <?php acp_empty(t('acp.rep.empty'), 'fa-bug'); ?>
300+ </div>
301+ </section>
302+
303+<?php else: ?>
304+
305+ <?php foreach ($reports as $statusId => $group): ?>
306+ <section class="acp-card">
307+ <details <?= in_array((int)$statusId, $collapsedStatus, true) ? '' : 'open' ?>>
308+ <summary class="acp-card-head" style="cursor:pointer;">
309+ <h2>
310+ <span class="acp-pill acp-pill--<?= h($statusTone[$statusId] ?? 'grey') ?>">
311+ <?= h($statusTypes[$statusId] ?? t('acp.rep.status_unknown')) ?>
312+ </span>
313+ </h2>
314+ <p><?= t('acp.rep.n_reports', ['n' => count($group)]) ?></p>
315+ </summary>
316+ <div class="acp-card-body is-flush">
317+ <div class="acp-table-wrap">
318+ <table class="acp-table">
319+ <thead>
320+ <tr>
321+ <th>#</th>
322+ <th><?= t('acp.rep.col_reporter') ?></th>
323+ <th><?= t('acp.rep.col_position') ?></th>
324+ <th><?= t('acp.rep.col_reported') ?></th>
325+ <th><?= t('acp.rep.col_description') ?></th>
326+ <th class="is-num">&nbsp;</th>
327+ </tr>
328+ </thead>
329+ <tbody>
330+ <?php foreach ($group as $r): ?>
331+ <tr>
332+ <td class="is-muted"><?= (int)$r['id'] ?></td>
333+ <td class="is-nowrap">
334+ <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$r['name']))) ?>" target="_blank" rel="noopener">
335+ <?= h((string)$r['name']) ?>
336+ </a>
337+ </td>
338+ <td class="is-nowrap"><code><?= (int)$r['posx'] ?>,<?= (int)$r['posy'] ?>,<?= (int)$r['posz'] ?></code></td>
339+ <td class="is-nowrap is-muted"><?= h(getClock((int)$r['date'], true, true)) ?></td>
340+ <td><?= h((string)$r['report_description']) ?></td>
341+ <td class="is-num is-nowrap">
342+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('reports', ['action' => 'edit', 'id' => (int)$r['id']])) ?>">
343+ <i class="fa fa-pencil"></i> <?= t('acp.rep.handle') ?>
344+ </a>
345+ </td>
346+ </tr>
347+ <?php endforeach; ?>
348+ </tbody>
349+ </table>
350+ </div>
351+ </div>
352+ </details>
353+ </section>
354+ <?php endforeach; ?>
355+
356+<?php endif; ?>
A admin/modules/scheduler.php +144-0 View file
@@ -0,0 +1,144 @@
1+<?php
2+/**
3+ * Title: Task Scheduler
4+ * Icon: fa-clock-o
5+ * Group: Operations
6+ * Order: 40
7+ * Description: Recurring maintenance without a real cron - backups, admin log pruning, health checks.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$tasks = scheduler_tasks();
16+
17+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
18+ $do = (string) ($_POST['do'] ?? '');
19+
20+ if ($do === 'save') {
21+ foreach ($tasks as $id => $task) {
22+ $enabled = !empty($_POST['enabled'][$id]) ? '1' : '0';
23+ $interval = max(1, intv($_POST['interval_hours'][$id] ?? $task['default_interval_hours']));
24+
25+ setting_set('scheduler:' . $id . ':enabled', $enabled);
26+ setting_set('scheduler:' . $id . ':interval_hours', (string) $interval);
27+
28+ if ($id === 'purge_admin_log') {
29+ $keepDays = max(1, intv($_POST['keep_days'] ?? 90));
30+ setting_set('scheduler:purge_admin_log:keep_days', (string) $keepDays);
31+ }
32+ }
33+
34+ acp_log('scheduler.settings_save');
35+ acp_flash_success(t_default('acp.sched.saved', 'Scheduler settings saved.'));
36+ acp_redirect('scheduler');
37+ }
38+
39+ if ($do === 'run_now') {
40+ $taskId = (string) ($_POST['task'] ?? '');
41+ if (!isset($tasks[$taskId])) {
42+ acp_flash_error(t_default('acp.sched.unknown_task', 'Unknown task.'));
43+ acp_redirect('scheduler');
44+ }
45+
46+ try {
47+ $summary = (string) call_user_func($tasks[$taskId]['run']);
48+ } catch (Throwable $e) {
49+ $summary = 'error: ' . $e->getMessage();
50+ }
51+
52+ scheduler_mark_run($taskId, $summary);
53+ acp_log('scheduler.' . $taskId, '', array('summary' => $summary, 'manual' => true));
54+ acp_flash_success(t_default('acp.sched.run_done', '{task} ran: {summary}', ['task' => $tasks[$taskId]['label'], 'summary' => $summary]));
55+ acp_redirect('scheduler');
56+ }
57+}
58+?>
59+
60+<section class="acp-card">
61+ <header class="acp-card-head">
62+ <h2><?= t_default('acp.sched.title', 'Task Scheduler') ?></h2>
63+ <p><?= t_default('acp.sched.sub', 'No real cron here - each task runs opportunistically on the next page load once its interval has passed (public pages, and any admin panel page).') ?></p>
64+ </header>
65+ <div class="acp-card-body is-flush">
66+ <form method="post">
67+ <?= acp_csrf_field() ?>
68+ <input type="hidden" name="do" value="save">
69+ <div class="acp-table-wrap">
70+ <table class="acp-table">
71+ <thead>
72+ <tr>
73+ <th><?= t_default('acp.sched.col_task', 'Task') ?></th>
74+ <th><?= t_default('acp.sched.col_enabled', 'Enabled') ?></th>
75+ <th><?= t_default('acp.sched.col_interval', 'Every (hours)') ?></th>
76+ <th><?= t_default('acp.sched.col_last_run', 'Last run') ?></th>
77+ <th><?= t_default('acp.sched.col_last_result', 'Last result') ?></th>
78+ <th class="is-num"><?= t_default('acp.sched.col_actions', 'Actions') ?></th>
79+ </tr>
80+ </thead>
81+ <tbody>
82+ <?php foreach ($tasks as $id => $task): ?>
83+ <?php
84+ $lastRun = scheduler_last_run($id);
85+ $lastResult = scheduler_last_result($id);
86+ $resultTone = 'grey';
87+ if ($lastResult !== '') {
88+ if (str_starts_with($lastResult, 'error')) {
89+ $resultTone = 'red';
90+ } elseif (str_contains($lastResult, 'issue')) {
91+ $resultTone = 'amber';
92+ } else {
93+ $resultTone = 'green';
94+ }
95+ }
96+ ?>
97+ <tr>
98+ <td>
99+ <strong><?= h(t_default('acp.sched.task.' . $id, $task['label'])) ?></strong>
100+ <?php if ($id === 'purge_admin_log'): ?>
101+ <div class="acp-field" style="margin-top:6px;">
102+ <label class="acp-label" for="sched_keep_days" style="font-weight:400;"><?= t_default('acp.sched.keep_days_label', 'Keep entries for (days)') ?></label>
103+ <input class="acp-input" id="sched_keep_days" name="keep_days" type="number" min="1" style="width:100px;" value="<?= (int) setting('scheduler:purge_admin_log:keep_days', '90') ?>">
104+ </div>
105+ <?php endif; ?>
106+ </td>
107+ <td>
108+ <input type="checkbox" name="enabled[<?= h($id) ?>]" value="1" <?= scheduler_enabled($id) ? 'checked' : '' ?>>
109+ </td>
110+ <td>
111+ <input class="acp-input" type="number" min="1" style="width:90px;" name="interval_hours[<?= h($id) ?>]" value="<?= scheduler_interval_hours($id, (int) $task['default_interval_hours']) ?>">
112+ </td>
113+ <td class="is-muted"><?= $lastRun > 0 ? h(getClock($lastRun, true)) : t_default('acp.sched.never', 'Never') ?></td>
114+ <td>
115+ <?php if ($lastResult !== ''): ?>
116+ <span class="acp-pill acp-pill--<?= $resultTone ?>"><?= h($lastResult) ?></span>
117+ <?php else: ?>
118+ <span class="is-muted">-</span>
119+ <?php endif; ?>
120+ </td>
121+ <td class="is-nowrap is-num">
122+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit" form="sched_run_<?= h($id) ?>">
123+ <i class="fa fa-play"></i> <?= t_default('acp.sched.run_now', 'Run now') ?>
124+ </button>
125+ </td>
126+ </tr>
127+ <?php endforeach; ?>
128+ </tbody>
129+ </table>
130+ </div>
131+ <div class="acp-actions">
132+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= t_default('acp.sched.save_btn', 'Save') ?></button>
133+ </div>
134+ </form>
135+
136+ <?php foreach ($tasks as $id => $task): ?>
137+ <form method="post" id="sched_run_<?= h($id) ?>">
138+ <?= acp_csrf_field() ?>
139+ <input type="hidden" name="do" value="run_now">
140+ <input type="hidden" name="task" value="<?= h($id) ?>">
141+ </form>
142+ <?php endforeach; ?>
143+ </div>
144+</section>
Top