xref: /plugin/lastseen/admin.php (revision 60518ac5e15ad2146a77203238dce0e34485568e)
1<?php
2/**
3 * Last Seen plugin — admin panel page.
4 *
5 * Lists every registered user with the time of their last authenticated
6 * activity. Appears in the Admin panel right after the User Manager.
7 */
8
9class admin_plugin_lastseen extends DokuWiki_Admin_Plugin
10{
11    /** Admin-only — last-seen data is mildly sensitive activity information. */
12    public function forAdminOnly()
13    {
14        return true;
15    }
16
17    /** Position in the admin menu — directly after User Manager (sort 2). */
18    public function getMenuSort()
19    {
20        return 3;
21    }
22
23    public function getMenuText($language)
24    {
25        return $this->getLang('menu');
26    }
27
28    /** Read-only page — no form submissions to process. */
29    public function handle()
30    {
31    }
32
33    /**
34     * Render the admin page.
35     */
36    public function html()
37    {
38        global $auth, $INPUT, $ID;
39
40        echo '<h1>' . hsc($this->getLang('menu')) . '</h1>';
41
42        /** @var helper_plugin_lastseen $hlp */
43        $hlp = plugin_load('helper', 'lastseen');
44        if ($hlp === null) {
45            echo '<div class="error">Helper component could not be loaded.</div>';
46            return;
47        }
48
49        // Some auth backends (certain LDAP/AD setups) cannot enumerate users.
50        // authplain can; degrade gracefully for the rest.
51        if (!$auth || !$auth->canDo('getUsers')) {
52            echo '<div class="error">' . hsc($this->getLang('no_userlist')) . '</div>';
53            return;
54        }
55
56        // retrieveUsers(0, 0): start at 0, limit 0 == all users.
57        // Returns [username => ['name' => ..., 'mail' => ..., 'grps' => []]].
58        $users = $auth->retrieveUsers(0, 0);
59        $seen  = $hlp->getAll();
60
61        // ---- sorting -------------------------------------------------
62        $sortable = ['login', 'name', 'grps', 'lastseen'];
63        $sort  = $INPUT->str('sort', 'lastseen');
64        if (!in_array($sort, $sortable, true)) {
65            $sort = 'lastseen';
66        }
67        $order = ($INPUT->str('order', 'desc') === 'asc') ? 'asc' : 'desc';
68
69        $rows = [];
70        foreach ($users as $login => $info) {
71            $rows[] = [
72                'login'    => $login,
73                'name'     => $info['name'] ?? '',
74                'grps'     => isset($info['grps']) ? implode(', ', (array) $info['grps']) : '',
75                'lastseen' => isset($seen[$login]) ? (int) $seen[$login] : 0, // 0 == never
76            ];
77        }
78
79        usort($rows, function ($a, $b) use ($sort, $order) {
80            switch ($sort) {
81                case 'login':
82                    $cmp = strcasecmp($a['login'], $b['login']);
83                    break;
84                case 'name':
85                    $cmp = strcasecmp($a['name'], $b['name']);
86                    break;
87                case 'grps':
88                    $cmp = strcasecmp($a['grps'], $b['grps']);
89                    break;
90                case 'lastseen':
91                default:
92                    $cmp = $a['lastseen'] <=> $b['lastseen'];
93                    break;
94            }
95            return ($order === 'asc') ? $cmp : -$cmp;
96        });
97
98        $showNever = (bool) $this->getConf('show_never');
99
100        // ---- render --------------------------------------------------
101        echo '<p>' . hsc($this->getLang('intro')) . '</p>';
102        echo '<div class="table">';
103        echo '<table class="inline plugin_lastseen">';
104        echo '<thead><tr>';
105        $this->headerCell('login',    $this->getLang('col_login'),    $sort, $order, $ID);
106        $this->headerCell('name',     $this->getLang('col_name'),     $sort, $order, $ID);
107        $this->headerCell('grps',     $this->getLang('col_grps'),     $sort, $order, $ID);
108        $this->headerCell('lastseen', $this->getLang('col_lastseen'), $sort, $order, $ID);
109        echo '</tr></thead><tbody>';
110
111        $count = 0;
112        foreach ($rows as $row) {
113            if ($row['lastseen'] === 0 && !$showNever) {
114                continue;
115            }
116            $count++;
117            echo '<tr>';
118            echo '<td>' . hsc($row['login']) . '</td>';
119            echo '<td>' . hsc($row['name']) . '</td>';
120            echo '<td>' . hsc($row['grps']) . '</td>';
121            if ($row['lastseen'] === 0) {
122                echo '<td class="lastseen_never">' . hsc($this->getLang('never')) . '</td>';
123            } else {
124                echo '<td>' . hsc(dformat($row['lastseen']))
125                    . ' <span class="lastseen_rel">('
126                    . hsc($this->relativeTime($row['lastseen'])) . ')</span></td>';
127            }
128            echo '</tr>';
129        }
130
131        echo '</tbody></table></div>';
132        echo '<p class="lastseen_count">' . sprintf($this->getLang('total'), $count) . '</p>';
133    }
134
135    /**
136     * Emit a sortable column header. Clicking a header sorts by that column;
137     * clicking the already-active column flips the direction.
138     *
139     * @param string $key   column key
140     * @param string $label visible header text
141     * @param string $sort  currently active sort column
142     * @param string $order currently active order (asc|desc)
143     * @param string $id    current page id (for the link target)
144     */
145    protected function headerCell($key, $label, $sort, $order, $id)
146    {
147        // If this column is already active, clicking flips the order;
148        // otherwise a fresh column starts ascending.
149        $newOrder = ($sort === $key && $order === 'asc') ? 'desc' : 'asc';
150
151        $arrow = '';
152        if ($sort === $key) {
153            // ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd)
154            $arrow = ($order === 'asc') ? ' &#9650;' : ' &#9660;';
155        }
156
157        // wl() already returns an HTML-safe URL — its default separator is the
158        // pre-encoded "&amp;". It must NOT be passed through hsc(): doing so
159        // double-encodes the ampersands ("&amp;" -> "&amp;amp;"), the browser
160        // then navigates to a URL containing a literal "&amp;", and the query
161        // parameters arrive mis-named ("amp;sort" instead of "sort") — which
162        // silently breaks sorting. The label, being plain text, IS hsc()'d.
163        $url = wl($id, [
164            'do'    => 'admin',
165            'page'  => 'lastseen',
166            'sort'  => $key,
167            'order' => $newOrder,
168        ]);
169
170        echo '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>';
171    }
172
173    /**
174     * Human-readable "time ago" string for a timestamp.
175     *
176     * @param int $timestamp
177     * @return string
178     */
179    protected function relativeTime($timestamp)
180    {
181        $diff = time() - $timestamp;
182        if ($diff < 0) {
183            $diff = 0;
184        }
185
186        if ($diff < 60) {
187            return $this->getLang('rel_now');
188        }
189        if ($diff < 3600) {
190            return sprintf($this->getLang('rel_minutes'), (int) floor($diff / 60));
191        }
192        if ($diff < 86400) {
193            return sprintf($this->getLang('rel_hours'), (int) floor($diff / 3600));
194        }
195        if ($diff < 86400 * 30) {
196            return sprintf($this->getLang('rel_days'), (int) floor($diff / 86400));
197        }
198        if ($diff < 86400 * 365) {
199            return sprintf($this->getLang('rel_months'), (int) floor($diff / (86400 * 30)));
200        }
201        return sprintf($this->getLang('rel_years'), (int) floor($diff / (86400 * 365)));
202    }
203}
204