1<?php 2 3if (!defined('DOKU_INC')) die(); 4 5use dokuwiki\Extension\Plugin; 6 7/** 8 * Last Seen plugin — storage helper. 9 * 10 * Owns the on-disk format and path for the per-user last-seen timestamps, 11 * shared between the action component (which records activity) and the admin 12 * component (which displays it). 13 */ 14 15class helper_plugin_lastseen extends Plugin 16{ 17 /** 18 * Absolute path to the storage file. 19 * 20 * Lives in the meta directory: it must survive cache clears (so not 21 * cachedir) and DokuWiki upgrades (so not inside the plugin folder). 22 * Format: a serialized [username => unix_timestamp] map. 23 * 24 * @return string 25 */ 26 public function getStorePath() 27 { 28 global $conf; 29 return $conf['metadir'] . '/_lastseen.dat'; 30 } 31 32 /** 33 * Read the full [username => timestamp] map. 34 * 35 * @return array empty array if there is no data yet or the file is corrupt 36 */ 37 public function getAll() 38 { 39 $path = $this->getStorePath(); 40 if (!file_exists($path)) { 41 return []; 42 } 43 $raw = io_readFile($path, false); 44 if ($raw === '') { 45 return []; 46 } 47 // allowed_classes => false: we only ever store scalars, and this 48 // blocks object-injection if the file is ever tampered with. 49 $data = unserialize($raw, ['allowed_classes' => false]); 50 return is_array($data) ? $data : []; 51 } 52 53 /** 54 * Last-seen timestamp for one user. 55 * 56 * @param string $user 57 * @return int|null unix timestamp, or null if never recorded 58 */ 59 public function getTimestamp($user) 60 { 61 $all = $this->getAll(); 62 return $all[$user] ?? null; 63 } 64 65 /** 66 * Record $user as seen "now" — throttled. 67 * 68 * The store is only rewritten if the user's existing timestamp is older 69 * than the configured interval. Under heavy browsing this turns hundreds 70 * of page views into at most one write per interval per user. 71 * 72 * @param string $user 73 * @return bool true if the store was updated, false if throttled/skipped 74 */ 75 public function record($user) 76 { 77 if ($user === '' || $user === null) { 78 return false; 79 } 80 81 $now = time(); 82 $interval = (int) $this->getConf('update_interval'); 83 84 // Fast path: read without locking. If the stored timestamp is still 85 // within the throttle window there is nothing to do — and this is 86 // what the vast majority of requests hit. 87 $current = $this->getTimestamp($user); 88 if ($current !== null && ($now - $current) < $interval) { 89 return false; 90 } 91 92 // Slow path: a write is due. Lock, re-read (a concurrent request may 93 // have just updated it), update, save atomically, unlock. 94 $path = $this->getStorePath(); 95 io_lock($path); 96 97 $all = $this->getAll(); 98 if (isset($all[$user]) && ($now - $all[$user]) < $interval) { 99 // Lost the race — another request updated it while we waited. 100 io_unlock($path); 101 return false; 102 } 103 $all[$user] = $now; 104 io_saveFile($path, serialize($all)); 105 106 io_unlock($path); 107 return true; 108 } 109} 110