getLang('menu');
}
/**
* Read-only page — no form submissions to process.
*
* @return void
*/
public function handle()
{
}
/**
* Render the admin page.
*/
public function html()
{
global $auth, $INPUT, $ID;
echo '
' . hsc($this->getLang('menu')) . ' ';
/** @var helper_plugin_lastseen $hlp */
$hlp = plugin_load('helper', 'lastseen');
if ($hlp === null) {
echo '' . hsc($this->getLang('helper_missing')) . '
';
return;
}
// Some auth backends (certain LDAP/AD setups) cannot enumerate users.
// authplain can; degrade gracefully for the rest.
if (!$auth || !$auth->canDo('getUsers')) {
echo '' . hsc($this->getLang('no_userlist')) . '
';
return;
}
$showMail = (bool) $this->getConf('show_mail');
$showGrps = (bool) $this->getConf('show_grps');
$showNever = (bool) $this->getConf('show_never');
$perPage = (int) $this->getConf('entries_per_page');
// visible columns, in display order
$cols = ['login', 'name'];
if ($showMail) {
$cols[] = 'mail';
}
if ($showGrps) {
$cols[] = 'grps';
}
$cols[] = 'lastseen';
// every visible column except "lastseen" is text-filterable
$filterCols = array_values(array_filter($cols, static function ($c) {
return $c !== 'lastseen';
}));
// ---- request parameters --------------------------------------
$sort = $INPUT->str('sort', 'lastseen');
if (!in_array($sort, $this->sortable, true)) {
$sort = 'lastseen';
}
// never sort by a hidden column
if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) {
$sort = 'lastseen';
}
$order = ($INPUT->str('order', 'desc') === 'asc') ? 'asc' : 'desc';
$filters = $this->activeFilters($filterCols);
// ---- data ----------------------------------------------------
// retrieveUsers(0, 0): start at 0, limit 0 == all users.
// Returns [username => ['name' => ..., 'mail' => ..., 'grps' => []]].
$users = $auth->retrieveUsers(0, 0);
$seen = $hlp->getAll();
$rows = [];
foreach ($users as $login => $info) {
$rows[] = [
'login' => $login,
'name' => $info['name'] ?? '',
'mail' => $info['mail'] ?? '',
'grps' => isset($info['grps']) ? implode(', ', (array) $info['grps']) : '',
'lastseen' => isset($seen[$login]) ? (int) $seen[$login] : 0, // 0 == never
];
}
// "never seen" rows are dropped before filtering/paging so the counts
// and page numbers reflect what is actually shown.
if (!$showNever) {
$rows = array_values(array_filter($rows, static function ($r) {
return $r['lastseen'] !== 0;
}));
}
$rows = $this->applyFilters($rows, $filters);
$rows = $this->sortRows($rows, $sort, $order);
$total = count($rows);
[$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage);
// ---- render --------------------------------------------------
echo '' . hsc($this->getLang('intro')) . '
';
$labels = [
'login' => $this->getLang('col_login'),
'name' => $this->getLang('col_name'),
'mail' => $this->getLang('col_mail'),
'grps' => $this->getLang('col_grps'),
'lastseen' => $this->getLang('col_lastseen'),
];
// GET form so the filter combines with sort links and bookmarks cleanly.
// The action URL's query string is dropped on submit, so every standing
// parameter travels as an explicit hidden field.
echo '';
echo $this->renderPager($page, $totalPages, $sort, $order, $filters, $ID);
if ($total > 0) {
echo ''
. hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '
';
}
}
// ---------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------
/**
* Read the active text filters from the request (the q[] array), keeping
* only the filterable columns and dropping blanks.
*
* @param string[] $filterCols column keys that accept a text filter
* @return array [column => trimmed search term]
*/
protected function activeFilters(array $filterCols)
{
global $INPUT;
$raw = $INPUT->arr('q');
$out = [];
foreach ($filterCols as $c) {
if (isset($raw[$c]) && is_string($raw[$c])) {
$term = trim($raw[$c]);
if ($term !== '') {
$out[$c] = $term;
}
}
}
return $out;
}
/**
* Keep only rows that match every active filter (substring, case-insensitive).
*
* @param array $rows
* @param array $filters [column => term]
* @return array
*/
protected function applyFilters(array $rows, array $filters)
{
if ($filters === []) {
return $rows;
}
return array_values(array_filter($rows, function ($row) use ($filters) {
foreach ($filters as $col => $term) {
if (!$this->matches($row[$col] ?? '', $term)) {
return false;
}
}
return true;
}));
}
/**
* Case-insensitive UTF-8 substring test.
*
* @param string $haystack
* @param string $needle
* @return bool
*/
protected function matches($haystack, $needle)
{
if ($needle === '') {
return true;
}
$h = PhpString::strtolower((string) $haystack);
$n = PhpString::strtolower((string) $needle);
return PhpString::strpos($h, $n) !== false;
}
// ---------------------------------------------------------------------
// Sorting & pagination
// ---------------------------------------------------------------------
/**
* Sort rows by the given column and direction.
*
* @param array $rows
* @param string $sort column key
* @param string $order 'asc' or 'desc'
* @return array
*/
protected function sortRows(array $rows, $sort, $order)
{
usort($rows, static function ($a, $b) use ($sort) {
if ($sort === 'lastseen') {
return $a['lastseen'] <=> $b['lastseen'];
}
return strcasecmp((string) ($a[$sort] ?? ''), (string) ($b[$sort] ?? ''));
});
if ($order === 'desc') {
$rows = array_reverse($rows);
}
return $rows;
}
/**
* Slice the rows for the current page.
*
* @param array $rows all rows (already filtered + sorted)
* @param int $perPage rows per page; <= 0 means "all on one page"
* @return array [pageRows, page, totalPages, from, to] — from/to are 1-based
* row numbers of the slice (0 when there are no rows)
*/
protected function paginate(array $rows, $perPage)
{
global $INPUT;
$total = count($rows);
if ($perPage <= 0) {
return [$rows, 1, 1, $total > 0 ? 1 : 0, $total];
}
$totalPages = max(1, (int) ceil($total / $perPage));
$page = $INPUT->int('pg', 1);
if ($page < 1) {
$page = 1;
}
if ($page > $totalPages) {
$page = $totalPages;
}
$offset = ($page - 1) * $perPage;
$slice = array_slice($rows, $offset, $perPage);
$from = $total > 0 ? $offset + 1 : 0;
$to = min($total, $offset + $perPage);
return [$slice, $page, $totalPages, $from, $to];
}
// ---------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------
/**
* Build the standing parameter set for an in-table link, with $overrides
* applied last. The active filters travel as the q[] array.
*
* @param array $overrides
* @param array $filters
* @return array
*/
protected function linkParams(array $overrides, array $filters)
{
$params = ['do' => 'admin', 'page' => 'lastseen'];
if ($filters !== []) {
$params['q'] = $filters;
}
return array_merge($params, $overrides);
}
/**
* Emit a sortable column header. Clicking a header sorts by that column;
* clicking the already-active column flips the direction. The current
* filter is preserved and the page resets to 1.
*
* @param string $key column key
* @param string $label visible header text
* @param string $sort currently active sort column
* @param string $order currently active order (asc|desc)
* @param array $filters active filters (preserved in the link)
* @param string $id current page id (for the link target)
* @return string
*/
protected function headerCell($key, $label, $sort, $order, array $filters, $id)
{
// If this column is already active, clicking flips the order;
// otherwise a fresh column starts ascending.
$newOrder = ($sort === $key && $order === 'asc') ? 'desc' : 'asc';
$arrow = '';
if ($sort === $key) {
// ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd)
$arrow = ($order === 'asc') ? ' ▲' : ' ▼';
}
// wl() already returns an HTML-safe URL — its default separator is the
// pre-encoded "&". It must NOT be passed through hsc(): doing so
// double-encodes the ampersands ("&" -> "&"), the browser
// then navigates to a URL containing a literal "&", and the query
// parameters arrive mis-named ("amp;sort" instead of "sort"). The label,
// being plain text, IS hsc()'d.
$url = wl($id, $this->linkParams(['sort' => $key, 'order' => $newOrder], $filters));
return '' . hsc($label) . $arrow . ' ';
}
/**
* Emit the per-column text-filter row: a text input under each filterable
* column, and the Search/Clear controls in the (non-filterable) last-seen
* cell.
*
* @param string[] $cols visible columns in order
* @param string[] $filterCols columns that accept a text filter
* @param array $filters active filters
* @param string $sort
* @param string $order
* @param string $id
* @return string
*/
protected function renderFilterRow(array $cols, array $filterCols, array $filters, $sort, $order, $id)
{
$html = '';
foreach ($cols as $c) {
if (in_array($c, $filterCols, true)) {
$val = isset($filters[$c]) ? hsc($filters[$c]) : '';
$html .= ' ';
} else {
// the last-seen column carries the action controls
$html .= '';
$html .= ''
. hsc($this->getLang('filter_search')) . ' ';
if ($filters !== []) {
$clear = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order], []));
$html .= ' '
. hsc($this->getLang('filter_clear')) . ' ';
}
$html .= ' ';
}
}
return $html . ' ';
}
/**
* Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Returns the
* empty string when there is only one page.
*
* @param int $page
* @param int $totalPages
* @param string $sort
* @param string $order
* @param array $filters
* @param string $id
* @return string
*/
protected function renderPager($page, $totalPages, $sort, $order, array $filters, $id)
{
if ($totalPages <= 1) {
return '';
}
$html = '';
}
/**
* One pager link (number or arrow), preserving sort + filter.
*
* @param string $id
* @param int $p target page
* @param string $sort
* @param string $order
* @param array $filters
* @param string $text already-safe link text (number or entity)
* @param string $titleKey lang key for the title attribute, or '' for none
* @return string
*/
protected function pagerLink($id, $p, $sort, $order, array $filters, $text, $titleKey)
{
$url = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order, 'pg' => $p], $filters));
$title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : '';
return '';
}
/**
* Page numbers to show around the current page, with 0 marking an elided
* gap. Always includes the first and last page.
*
* @param int $page
* @param int $totalPages
* @return int[]
*/
protected function pageWindow($page, $totalPages)
{
$window = 2;
$keep = [];
for ($i = 1; $i <= $totalPages; $i++) {
if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) {
$keep[] = $i;
}
}
$out = [];
$prev = 0;
foreach ($keep as $p) {
if ($prev && ($p - $prev) > 1) {
$out[] = 0; // gap marker
}
$out[] = $p;
$prev = $p;
}
return $out;
}
/**
* Human-readable "time ago" string for a timestamp.
*
* @param int $timestamp
* @return string
*/
protected function relativeTime($timestamp)
{
$diff = time() - $timestamp;
if ($diff < 0) {
$diff = 0;
}
if ($diff < 60) {
return $this->getLang('rel_now');
}
if ($diff < 3600) {
$n = (int) floor($diff / 60);
return sprintf($this->getLang($n === 1 ? 'rel_minute' : 'rel_minutes'), $n);
}
if ($diff < 86400) {
$n = (int) floor($diff / 3600);
return sprintf($this->getLang($n === 1 ? 'rel_hour' : 'rel_hours'), $n);
}
if ($diff < 86400 * 30) {
$n = (int) floor($diff / 86400);
return sprintf($this->getLang($n === 1 ? 'rel_day' : 'rel_days'), $n);
}
if ($diff < 86400 * 365) {
$n = (int) floor($diff / (86400 * 30));
return sprintf($this->getLang($n === 1 ? 'rel_month' : 'rel_months'), $n);
}
$n = (int) floor($diff / (86400 * 365));
return sprintf($this->getLang($n === 1 ? 'rel_year' : 'rel_years'), $n);
}
}