xref: /plugin/lastseen/admin.php (revision 0c66c82e53105aa4c75f11f0c3fa59f56f42254a)
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') ? ' &#9650;' : ' &#9660;';
385        }
386
387        // wl() already returns an HTML-safe URL — its default separator is the
388        // pre-encoded "&amp;". It must NOT be passed through hsc(): doing so
389        // double-encodes the ampersands ("&amp;" -> "&amp;amp;"), the browser
390        // then navigates to a URL containing a literal "&amp;", 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, '&#8249;', 'pager_prev');
457        } else {
458            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
459        }
460
461        foreach ($this->pageWindow($page, $totalPages) as $p) {
462            if ($p === 0) {
463                $html .= '<span class="pager_gap">&#8230;</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, '&#8250;', 'pager_next');
473        } else {
474            $html .= '<span class="pager_btn pager_disabled">&#8250;</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