| @@ -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 | + | <?php foreach (theme_menu_items('main') as $item): ?> | |
| 353 | + | <a href="<?= $item['url'] ?>"><?= $item['label'] ?></a> | |
| 354 | + | <?php endforeach; ?></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">— </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> |
| @@ -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> |
| @@ -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> |
| @@ -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 > 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; ?> |
| @@ -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 ? '—' : ($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">—</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> |
| @@ -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"> </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> |
| @@ -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'] !== ''): ?>· <?= h($plugin['author']) ?><?php endif; ?> | |
| 237 | + | <?php if ($plugin['url'] !== ''): ?> | |
| 238 | + | · <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']) : '—' ?> | |
| 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(); ?> |
| @@ -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(); ?> |
| @@ -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"> </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; ?> |
| @@ -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> |