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
10if (!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
22function 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
27function 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
35if ($_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"> </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"> </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>
530