xref: /plugin/lastseen/helper.php (revision 51e72b81450b695de7d55fc5789cf4f3555d8e81)
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        //
95        // We lock a *sentinel* path (.lock suffix) rather than $path itself.
96        // io_saveFile() calls io_lock()/io_unlock() on its own argument, so
97        // locking $path here would cause a double-lock on the same md5 key:
98        // the inner lock busy-waits the full 3-second stale-lock timeout on
99        // every write, then tears down the directory prematurely — eliminating
100        // the mutual-exclusion we intended while stalling the request.
101        $path = $this->getStorePath();
102        $lock = $path . '.lock';
103        io_lock($lock);
104
105        $all = $this->getAll();
106        if (isset($all[$user]) && ($now - $all[$user]) < $interval) {
107            // Lost the race — another request updated it while we waited.
108            io_unlock($lock);
109            return false;
110        }
111        $all[$user] = $now;
112        io_saveFile($path, serialize($all));
113
114        io_unlock($lock);
115        return true;
116    }
117}
118