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