1<?php 2 3if (!defined('DOKU_INC')) die(); 4 5use dokuwiki\Extension\AdminPlugin; 6use dokuwiki\Utf8\PhpString; 7 8/** 9 * Last Seen plugin — admin panel page. 10 * 11 * Lists every registered user with the time of their last authenticated 12 * activity. Appears in the Admin panel right after the User Manager. 13 * 14 * The table is sortable (any column), filterable (a per-column text-filter 15 * row, substring and case-insensitive, like the User Manager but JS-free) and 16 * paginated with numbered page links. Which columns appear and how many rows 17 * fill a page are configurable. 18 */ 19 20class admin_plugin_lastseen extends AdminPlugin 21{ 22 /** @var string[] columns that may be sorted (subject to visibility) */ 23 protected $sortable = ['login', 'name', 'mail', 'grps', 'lastseen']; 24 25 /** 26 * Admin-only — last-seen data is mildly sensitive activity information. 27 * 28 * @return bool 29 */ 30 public function forAdminOnly() 31 { 32 return true; 33 } 34 35 /** 36 * Position in the admin menu. 37 * 38 * @return int 39 */ 40 public function getMenuSort() 41 { 42 return 1000; 43 } 44 45 /** 46 * @param string $language 47 * @return string 48 */ 49 public function getMenuText($language) 50 { 51 return $this->getLang('menu'); 52 } 53 54 /** 55 * Read-only page — no form submissions to process. 56 * 57 * @return void 58 */ 59 public function handle() 60 { 61 } 62 63 /** 64 * Render the admin page. 65 */ 66 public function html() 67 { 68 global $auth, $INPUT, $ID; 69 70 echo '<h1>' . hsc($this->getLang('menu')) . '</h1>'; 71 72 /** @var helper_plugin_lastseen $hlp */ 73 $hlp = plugin_load('helper', 'lastseen'); 74 if ($hlp === null) { 75 echo '<div class="error">' . hsc($this->getLang('helper_missing')) . '</div>'; 76 return; 77 } 78 79 // Some auth backends (certain LDAP/AD setups) cannot enumerate users. 80 // authplain can; degrade gracefully for the rest. 81 if (!$auth || !$auth->canDo('getUsers')) { 82 echo '<div class="error">' . hsc($this->getLang('no_userlist')) . '</div>'; 83 return; 84 } 85 86 $showMail = (bool) $this->getConf('show_mail'); 87 $showGrps = (bool) $this->getConf('show_grps'); 88 $showNever = (bool) $this->getConf('show_never'); 89 $perPage = (int) $this->getConf('entries_per_page'); 90 91 // visible columns, in display order 92 $cols = ['login', 'name']; 93 if ($showMail) { 94 $cols[] = 'mail'; 95 } 96 if ($showGrps) { 97 $cols[] = 'grps'; 98 } 99 $cols[] = 'lastseen'; 100 101 // every visible column except "lastseen" is text-filterable 102 $filterCols = array_values(array_filter($cols, static function ($c) { 103 return $c !== 'lastseen'; 104 })); 105 106 // ---- request parameters -------------------------------------- 107 $sort = $INPUT->str('sort', 'lastseen'); 108 if (!in_array($sort, $this->sortable, true)) { 109 $sort = 'lastseen'; 110 } 111 // never sort by a hidden column 112 if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) { 113 $sort = 'lastseen'; 114 } 115 $order = ($INPUT->str('order', 'desc') === 'asc') ? 'asc' : 'desc'; 116 $filters = $this->activeFilters($filterCols); 117 118 // ---- data ---------------------------------------------------- 119 // retrieveUsers(0, 0): start at 0, limit 0 == all users. 120 // Returns [username => ['name' => ..., 'mail' => ..., 'grps' => []]]. 121 $users = $auth->retrieveUsers(0, 0); 122 $seen = $hlp->getAll(); 123 124 $rows = []; 125 foreach ($users as $login => $info) { 126 $rows[] = [ 127 'login' => $login, 128 'name' => $info['name'] ?? '', 129 'mail' => $info['mail'] ?? '', 130 'grps' => isset($info['grps']) ? implode(', ', (array) $info['grps']) : '', 131 'lastseen' => isset($seen[$login]) ? (int) $seen[$login] : 0, // 0 == never 132 ]; 133 } 134 135 // "never seen" rows are dropped before filtering/paging so the counts 136 // and page numbers reflect what is actually shown. 137 if (!$showNever) { 138 $rows = array_values(array_filter($rows, static function ($r) { 139 return $r['lastseen'] !== 0; 140 })); 141 } 142 143 $rows = $this->applyFilters($rows, $filters); 144 $rows = $this->sortRows($rows, $sort, $order); 145 $total = count($rows); 146 147 [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage); 148 149 // ---- render -------------------------------------------------- 150 echo '<p>' . hsc($this->getLang('intro')) . '</p>'; 151 152 $labels = [ 153 'login' => $this->getLang('col_login'), 154 'name' => $this->getLang('col_name'), 155 'mail' => $this->getLang('col_mail'), 156 'grps' => $this->getLang('col_grps'), 157 'lastseen' => $this->getLang('col_lastseen'), 158 ]; 159 160 // GET form so the filter combines with sort links and bookmarks cleanly. 161 // The action URL's query string is dropped on submit, so every standing 162 // parameter travels as an explicit hidden field. 163 echo '<form class="lastseen_filter" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 164 echo '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 165 echo '<input type="hidden" name="do" value="admin" />'; 166 echo '<input type="hidden" name="page" value="lastseen" />'; 167 echo '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 168 echo '<input type="hidden" name="order" value="' . hsc($order) . '" />'; 169 echo '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1 170 171 echo '<div class="table">'; 172 echo '<table class="inline plugin_lastseen">'; 173 echo '<thead>'; 174 echo '<tr>'; 175 foreach ($cols as $c) { 176 echo $this->headerCell($c, $labels[$c], $sort, $order, $filters, $ID); 177 } 178 echo '</tr>'; 179 echo $this->renderFilterRow($cols, $filterCols, $filters, $sort, $order, $ID); 180 echo '</thead>'; 181 echo '<tbody>'; 182 183 if ($total === 0) { 184 echo '<tr><td colspan="' . count($cols) . '" class="lastseen_none">' 185 . hsc($this->getLang('none')) . '</td></tr>'; 186 } else { 187 foreach ($pageRows as $row) { 188 echo '<tr>'; 189 foreach ($cols as $c) { 190 if ($c !== 'lastseen') { 191 echo '<td>' . hsc($row[$c]) . '</td>'; 192 } elseif ($row['lastseen'] === 0) { 193 echo '<td class="lastseen_never">' . hsc($this->getLang('never')) . '</td>'; 194 } else { 195 echo '<td>' . hsc(dformat($row['lastseen'])) 196 . ' <span class="lastseen_rel">(' 197 . hsc($this->relativeTime($row['lastseen'])) . ')</span></td>'; 198 } 199 } 200 echo '</tr>'; 201 } 202 } 203 204 echo '</tbody></table></div>'; 205 echo '</form>'; 206 207 echo $this->renderPager($page, $totalPages, $sort, $order, $filters, $ID); 208 209 if ($total > 0) { 210 echo '<p class="lastseen_count">' 211 . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>'; 212 } 213 } 214 215 // --------------------------------------------------------------------- 216 // Filtering 217 // --------------------------------------------------------------------- 218 219 /** 220 * Read the active text filters from the request (the q[] array), keeping 221 * only the filterable columns and dropping blanks. 222 * 223 * @param string[] $filterCols column keys that accept a text filter 224 * @return array [column => trimmed search term] 225 */ 226 protected function activeFilters(array $filterCols) 227 { 228 global $INPUT; 229 $raw = $INPUT->arr('q'); 230 $out = []; 231 foreach ($filterCols as $c) { 232 if (isset($raw[$c]) && is_string($raw[$c])) { 233 $term = trim($raw[$c]); 234 if ($term !== '') { 235 $out[$c] = $term; 236 } 237 } 238 } 239 return $out; 240 } 241 242 /** 243 * Keep only rows that match every active filter (substring, case-insensitive). 244 * 245 * @param array $rows 246 * @param array $filters [column => term] 247 * @return array 248 */ 249 protected function applyFilters(array $rows, array $filters) 250 { 251 if ($filters === []) { 252 return $rows; 253 } 254 return array_values(array_filter($rows, function ($row) use ($filters) { 255 foreach ($filters as $col => $term) { 256 if (!$this->matches($row[$col] ?? '', $term)) { 257 return false; 258 } 259 } 260 return true; 261 })); 262 } 263 264 /** 265 * Case-insensitive UTF-8 substring test. 266 * 267 * @param string $haystack 268 * @param string $needle 269 * @return bool 270 */ 271 protected function matches($haystack, $needle) 272 { 273 if ($needle === '') { 274 return true; 275 } 276 $h = PhpString::strtolower((string) $haystack); 277 $n = PhpString::strtolower((string) $needle); 278 return PhpString::strpos($h, $n) !== false; 279 } 280 281 // --------------------------------------------------------------------- 282 // Sorting & pagination 283 // --------------------------------------------------------------------- 284 285 /** 286 * Sort rows by the given column and direction. 287 * 288 * @param array $rows 289 * @param string $sort column key 290 * @param string $order 'asc' or 'desc' 291 * @return array 292 */ 293 protected function sortRows(array $rows, $sort, $order) 294 { 295 usort($rows, static function ($a, $b) use ($sort) { 296 if ($sort === 'lastseen') { 297 return $a['lastseen'] <=> $b['lastseen']; 298 } 299 return strcasecmp((string) ($a[$sort] ?? ''), (string) ($b[$sort] ?? '')); 300 }); 301 if ($order === 'desc') { 302 $rows = array_reverse($rows); 303 } 304 return $rows; 305 } 306 307 /** 308 * Slice the rows for the current page. 309 * 310 * @param array $rows all rows (already filtered + sorted) 311 * @param int $perPage rows per page; <= 0 means "all on one page" 312 * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based 313 * row numbers of the slice (0 when there are no rows) 314 */ 315 protected function paginate(array $rows, $perPage) 316 { 317 global $INPUT; 318 $total = count($rows); 319 320 if ($perPage <= 0) { 321 return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; 322 } 323 324 $totalPages = max(1, (int) ceil($total / $perPage)); 325 $page = $INPUT->int('pg', 1); 326 if ($page < 1) { 327 $page = 1; 328 } 329 if ($page > $totalPages) { 330 $page = $totalPages; 331 } 332 333 $offset = ($page - 1) * $perPage; 334 $slice = array_slice($rows, $offset, $perPage); 335 $from = $total > 0 ? $offset + 1 : 0; 336 $to = min($total, $offset + $perPage); 337 338 return [$slice, $page, $totalPages, $from, $to]; 339 } 340 341 // --------------------------------------------------------------------- 342 // Rendering helpers 343 // --------------------------------------------------------------------- 344 345 /** 346 * Build the standing parameter set for an in-table link, with $overrides 347 * applied last. The active filters travel as the q[] array. 348 * 349 * @param array $overrides 350 * @param array $filters 351 * @return array 352 */ 353 protected function linkParams(array $overrides, array $filters) 354 { 355 $params = ['do' => 'admin', 'page' => 'lastseen']; 356 if ($filters !== []) { 357 $params['q'] = $filters; 358 } 359 return array_merge($params, $overrides); 360 } 361 362 /** 363 * Emit a sortable column header. Clicking a header sorts by that column; 364 * clicking the already-active column flips the direction. The current 365 * filter is preserved and the page resets to 1. 366 * 367 * @param string $key column key 368 * @param string $label visible header text 369 * @param string $sort currently active sort column 370 * @param string $order currently active order (asc|desc) 371 * @param array $filters active filters (preserved in the link) 372 * @param string $id current page id (for the link target) 373 * @return string 374 */ 375 protected function headerCell($key, $label, $sort, $order, array $filters, $id) 376 { 377 // If this column is already active, clicking flips the order; 378 // otherwise a fresh column starts ascending. 379 $newOrder = ($sort === $key && $order === 'asc') ? 'desc' : 'asc'; 380 381 $arrow = ''; 382 if ($sort === $key) { 383 // ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd) 384 $arrow = ($order === 'asc') ? ' ▲' : ' ▼'; 385 } 386 387 // wl() already returns an HTML-safe URL — its default separator is the 388 // pre-encoded "&". It must NOT be passed through hsc(): doing so 389 // double-encodes the ampersands ("&" -> "&amp;"), the browser 390 // then navigates to a URL containing a literal "&", and the query 391 // parameters arrive mis-named ("amp;sort" instead of "sort"). The label, 392 // being plain text, IS hsc()'d. 393 $url = wl($id, $this->linkParams(['sort' => $key, 'order' => $newOrder], $filters)); 394 395 return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 396 } 397 398 /** 399 * Emit the per-column text-filter row: a text input under each filterable 400 * column, and the Search/Clear controls in the (non-filterable) last-seen 401 * cell. 402 * 403 * @param string[] $cols visible columns in order 404 * @param string[] $filterCols columns that accept a text filter 405 * @param array $filters active filters 406 * @param string $sort 407 * @param string $order 408 * @param string $id 409 * @return string 410 */ 411 protected function renderFilterRow(array $cols, array $filterCols, array $filters, $sort, $order, $id) 412 { 413 $html = '<tr class="lastseen_filterrow">'; 414 foreach ($cols as $c) { 415 if (in_array($c, $filterCols, true)) { 416 $val = isset($filters[$c]) ? hsc($filters[$c]) : ''; 417 $html .= '<td><input type="text" name="q[' . hsc($c) . ']" class="edit" value="' 418 . $val . '" /></td>'; 419 } else { 420 // the last-seen column carries the action controls 421 $html .= '<td class="lastseen_filteractions">'; 422 $html .= '<button type="submit" class="button">' 423 . hsc($this->getLang('filter_search')) . '</button>'; 424 if ($filters !== []) { 425 $clear = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order], [])); 426 $html .= ' <a class="lastseen_clear" href="' . $clear . '">' 427 . hsc($this->getLang('filter_clear')) . '</a>'; 428 } 429 $html .= '</td>'; 430 } 431 } 432 return $html . '</tr>'; 433 } 434 435 /** 436 * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Returns the 437 * empty string when there is only one page. 438 * 439 * @param int $page 440 * @param int $totalPages 441 * @param string $sort 442 * @param string $order 443 * @param array $filters 444 * @param string $id 445 * @return string 446 */ 447 protected function renderPager($page, $totalPages, $sort, $order, array $filters, $id) 448 { 449 if ($totalPages <= 1) { 450 return ''; 451 } 452 453 $html = '<nav class="lastseen_pager" aria-label="' . hsc($this->getLang('pager_label')) . '">'; 454 455 if ($page > 1) { 456 $html .= $this->pagerLink($id, $page - 1, $sort, $order, $filters, '‹', 'pager_prev'); 457 } else { 458 $html .= '<span class="pager_btn pager_disabled">‹</span>'; 459 } 460 461 foreach ($this->pageWindow($page, $totalPages) as $p) { 462 if ($p === 0) { 463 $html .= '<span class="pager_gap">…</span>'; 464 } elseif ($p === $page) { 465 $html .= '<span class="pager_cur">' . $p . '</span>'; 466 } else { 467 $html .= $this->pagerLink($id, $p, $sort, $order, $filters, (string) $p, ''); 468 } 469 } 470 471 if ($page < $totalPages) { 472 $html .= $this->pagerLink($id, $page + 1, $sort, $order, $filters, '›', 'pager_next'); 473 } else { 474 $html .= '<span class="pager_btn pager_disabled">›</span>'; 475 } 476 477 return $html . '</nav>'; 478 } 479 480 /** 481 * One pager link (number or arrow), preserving sort + filter. 482 * 483 * @param string $id 484 * @param int $p target page 485 * @param string $sort 486 * @param string $order 487 * @param array $filters 488 * @param string $text already-safe link text (number or entity) 489 * @param string $titleKey lang key for the title attribute, or '' for none 490 * @return string 491 */ 492 protected function pagerLink($id, $p, $sort, $order, array $filters, $text, $titleKey) 493 { 494 $url = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order, 'pg' => $p], $filters)); 495 $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; 496 return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>'; 497 } 498 499 /** 500 * Page numbers to show around the current page, with 0 marking an elided 501 * gap. Always includes the first and last page. 502 * 503 * @param int $page 504 * @param int $totalPages 505 * @return int[] 506 */ 507 protected function pageWindow($page, $totalPages) 508 { 509 $window = 2; 510 $keep = []; 511 for ($i = 1; $i <= $totalPages; $i++) { 512 if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { 513 $keep[] = $i; 514 } 515 } 516 517 $out = []; 518 $prev = 0; 519 foreach ($keep as $p) { 520 if ($prev && ($p - $prev) > 1) { 521 $out[] = 0; // gap marker 522 } 523 $out[] = $p; 524 $prev = $p; 525 } 526 return $out; 527 } 528 529 /** 530 * Human-readable "time ago" string for a timestamp. 531 * 532 * @param int $timestamp 533 * @return string 534 */ 535 protected function relativeTime($timestamp) 536 { 537 $diff = time() - $timestamp; 538 if ($diff < 0) { 539 $diff = 0; 540 } 541 542 if ($diff < 60) { 543 return $this->getLang('rel_now'); 544 } 545 if ($diff < 3600) { 546 $n = (int) floor($diff / 60); 547 return sprintf($this->getLang($n === 1 ? 'rel_minute' : 'rel_minutes'), $n); 548 } 549 if ($diff < 86400) { 550 $n = (int) floor($diff / 3600); 551 return sprintf($this->getLang($n === 1 ? 'rel_hour' : 'rel_hours'), $n); 552 } 553 if ($diff < 86400 * 30) { 554 $n = (int) floor($diff / 86400); 555 return sprintf($this->getLang($n === 1 ? 'rel_day' : 'rel_days'), $n); 556 } 557 if ($diff < 86400 * 365) { 558 $n = (int) floor($diff / (86400 * 30)); 559 return sprintf($this->getLang($n === 1 ? 'rel_month' : 'rel_months'), $n); 560 } 561 $n = (int) floor($diff / (86400 * 365)); 562 return sprintf($this->getLang($n === 1 ? 'rel_year' : 'rel_years'), $n); 563 } 564} 565