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