reports.php

main 357 lines · 11.6 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
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
10if (!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// ---------------------------------------------------------------------------
42if ($_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;
175if (is_array($rows)) {
176 foreach ($rows as $r) {
177 $reports[(int)$r['status']][(int)$r['id']] = $r;
178 $total++;
179 }
180}
181ksort($reports);
182
183// Report being edited
184$editing = null;
185if (($_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; ?>
357
Top