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') ? ' ▲' : ' ▼'; 177 } 178 179 // wl() already returns an HTML-safe URL — its default separator is the 180 // pre-encoded "&". It must NOT be passed through hsc(): doing so 181 // double-encodes the ampersands ("&" -> "&amp;"), the browser 182 // then navigates to a URL containing a literal "&", 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