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