xref: /plugin/usersettings/helper.php (revision 49b74e0a20d271d13e295d5f68707f57e70072a5)
1<?php
2
3/**
4 * User Settings plugin — storage and registration helper.
5 *
6 * This component owns three things:
7 *
8 *   1. The per-user preference store. One JSON file per user under the meta
9 *      directory, holding {key => {value, changed_at, changed_by}}. JSON is
10 *      used deliberately so the files are human-readable and easy to inspect
11 *      or back up.
12 *
13 *   2. The PLUGIN_USERSETTINGS_REGISTER event. Other plugins (and template
14 *      companion plugins) hook it to declare their own toggles, so this
15 *      plugin never needs editing when a new toggle is added elsewhere.
16 *
17 *   3. The read/write API used by this plugin's own settings page and admin
18 *      table, and by feature plugins that want to read a user's preference.
19 *
20 * Access control is intentionally NOT enforced here — this is a storage
21 * primitive. Callers are responsible: the settings page only ever writes the
22 * current user's own file, and the admin component is gated to admins by
23 * DokuWiki's admin dispatcher.
24 */
25
26// must be run within DokuWiki
27if (!defined('DOKU_INC')) die();
28
29use dokuwiki\Extension\Plugin;
30
31class helper_plugin_usersettings extends Plugin
32{
33    /** Event other plugins hook to declare their toggles. */
34    const REGISTER_EVENT = 'PLUGIN_USERSETTINGS_REGISTER';
35
36    /** @var array|null cached, normalised toggle definitions for this request */
37    protected $toggles = null;
38
39    // ---------------------------------------------------------------------
40    //  Storage location
41    // ---------------------------------------------------------------------
42
43    /**
44     * Directory holding the per-user preference files.
45     *
46     * Lives under the meta directory: persistent (unlike the cache dir) and,
47     * because it is inside data/, not served by the web server.
48     *
49     * @return string absolute path (created if missing)
50     */
51    public function getStorePath()
52    {
53        global $conf;
54        $path = $conf['metadir'] . '/usersettings';
55        io_mkdir_p($path);
56        return $path;
57    }
58
59    /**
60     * Absolute path of one user's preference file. The username is
61     * rawurlencoded so any character is safe in a filename.
62     *
63     * @param string $user
64     * @return string
65     */
66    protected function getUserFile($user)
67    {
68        return $this->getStorePath() . '/' . rawurlencode($user) . '.json';
69    }
70
71    // ---------------------------------------------------------------------
72    //  Raw per-user data
73    // ---------------------------------------------------------------------
74
75    /**
76     * Load one user's stored preferences.
77     *
78     * @param string $user
79     * @return array  [key => ['value'=>mixed, 'changed_at'=>int, 'changed_by'=>string]]
80     *                empty array if the user has no stored preferences
81     */
82    public function loadUserData($user)
83    {
84        $file = $this->getUserFile($user);
85        if (!file_exists($file)) {
86            return [];
87        }
88        $raw = io_readFile($file, false);
89        if ($raw === '') {
90            return [];
91        }
92        $data = json_decode($raw, true);
93        return is_array($data) ? $data : [];
94    }
95
96    // ---------------------------------------------------------------------
97    //  Registered toggles
98    // ---------------------------------------------------------------------
99
100    /**
101     * Collect the toggle definitions contributed by other plugins.
102     *
103     * Fires PLUGIN_USERSETTINGS_REGISTER; handlers append definition arrays
104     * to the event data. Each definition is validated and normalised;
105     * unusable ones are dropped, and a key registered more than once keeps
106     * its first registration. Cached for the duration of the request.
107     *
108     * @return array  [key => normalised definition]
109     */
110    public function getRegisteredToggles()
111    {
112        if ($this->toggles !== null) {
113            return $this->toggles;
114        }
115
116        $raw = [];
117        \dokuwiki\Extension\Event::createAndTrigger(self::REGISTER_EVENT, $raw);
118
119        $toggles = [];
120        foreach ((array) $raw as $def) {
121            $def = $this->normaliseDefinition($def);
122            if ($def === null) {
123                continue; // invalid — skip
124            }
125            if (isset($toggles[$def['key']])) {
126                continue; // duplicate key — first registration wins
127            }
128            $toggles[$def['key']] = $def;
129        }
130
131        $this->toggles = $toggles;
132        return $toggles;
133    }
134
135    /**
136     * Get one toggle definition by key.
137     *
138     * @param string $key
139     * @return array|null
140     */
141    public function getToggle($key)
142    {
143        $toggles = $this->getRegisteredToggles();
144        return $toggles[$key] ?? null;
145    }
146
147    /**
148     * Validate and normalise one toggle definition supplied by a registering
149     * plugin. Returns a clean definition with every expected field present,
150     * or null if the definition is unusable.
151     *
152     * Expected input fields:
153     *   key      (string, required)  unique identifier; [A-Za-z0-9_] only
154     *   label    (string, required)  shown on the settings page / admin table
155     *   type     (string)            'checkbox' (default) or 'select'
156     *   default  (mixed)             default value
157     *   options  (array)             value=>label map; required for 'select'
158     *   desc     (string, optional)  help text
159     *   plugin   (string, optional)  id of the registering plugin/template
160     *
161     * @param mixed $def
162     * @return array|null
163     */
164    protected function normaliseDefinition($def)
165    {
166        if (!is_array($def)) return null;
167        if (empty($def['key']) || !is_string($def['key'])) return null;
168        if (empty($def['label']) || !is_string($def['label'])) return null;
169
170        // the key is used as a storage key and an HTML form field name
171        if (!preg_match('/^[A-Za-z0-9_]+$/', $def['key'])) return null;
172
173        $type = $def['type'] ?? 'checkbox';
174        if (!in_array($type, ['checkbox', 'select'], true)) {
175            $type = 'checkbox';
176        }
177
178        $clean = [
179            'key'    => $def['key'],
180            'label'  => $def['label'],
181            'type'   => $type,
182            'desc'   => (isset($def['desc']) && is_string($def['desc'])) ? $def['desc'] : '',
183            'plugin' => (isset($def['plugin']) && is_string($def['plugin'])) ? $def['plugin'] : '',
184        ];
185
186        if ($type === 'select') {
187            // a select needs a non-empty value=>label option map
188            if (empty($def['options']) || !is_array($def['options'])) {
189                return null;
190            }
191            $clean['options'] = $def['options'];
192            // the default must be one of the option keys
193            $default = $def['default'] ?? null;
194            if ($default === null || !array_key_exists($default, $def['options'])) {
195                $default = array_key_first($def['options']);
196            }
197            $clean['default'] = $default;
198        } else {
199            // checkbox: default coerced to a clean 0/1
200            $clean['default'] = empty($def['default']) ? 0 : 1;
201        }
202
203        return $clean;
204    }
205
206    // ---------------------------------------------------------------------
207    //  Read API
208    // ---------------------------------------------------------------------
209
210    /**
211     * The effective value of a preference for a user: the value the user has
212     * explicitly stored, or — if they never set one — the toggle's registered
213     * default. This is the method feature plugins call.
214     *
215     * @param string      $key
216     * @param string|null $user  defaults to the current user
217     * @return mixed  the value, or null if $key is not a registered toggle
218     */
219    public function getPreference($key, $user = null)
220    {
221        $toggle = $this->getToggle($key);
222        if ($toggle === null) {
223            return null; // unknown toggle
224        }
225
226        if ($user === null) {
227            global $INPUT;
228            $user = $INPUT->server->str('REMOTE_USER');
229        }
230        if ($user === '') {
231            return $toggle['default']; // anonymous — no stored preferences
232        }
233
234        $data = $this->loadUserData($user);
235        if (isset($data[$key]) && array_key_exists('value', $data[$key])) {
236            return $data[$key]['value'];
237        }
238        return $toggle['default'];
239    }
240
241    /**
242     * The stored record for one key (value + changed_at + changed_by), or null
243     * if the user has no explicit value for it. Used by the admin table to
244     * show who changed a setting and when.
245     *
246     * @param string $key
247     * @param string $user
248     * @return array|null
249     */
250    public function getRecord($key, $user)
251    {
252        $data = $this->loadUserData($user);
253        return $data[$key] ?? null;
254    }
255
256    // ---------------------------------------------------------------------
257    //  Write API
258    // ---------------------------------------------------------------------
259
260    /**
261     * Write one or more preferences for a user.
262     *
263     * Each value is validated against its registered toggle definition:
264     * unknown keys are ignored, checkbox values coerced to 0/1, and select
265     * values must be a defined option. The timestamp and the actor (whoever
266     * is making the change — the user themselves, or an admin) are recorded.
267     *
268     * @param array  $values  [key => value]
269     * @param string $user    whose preferences are being written
270     * @param string $actor   who is making the change
271     * @return bool  true on success (also true when there was nothing to change)
272     */
273    public function setPreferences(array $values, $user, $actor)
274    {
275        if ($user === '' || $user === null || $actor === '' || $actor === null) {
276            return false;
277        }
278
279        $toggles = $this->getRegisteredToggles();
280        $file    = $this->getUserFile($user);
281
282        // Use a distinct lock key so our read-modify-write cycle serialises
283        // correctly without colliding with io_saveFile()'s own internal lock on
284        // the same file.  Wrapping io_saveFile() in io_lock($file)/io_unlock($file)
285        // would busy-wait 3 seconds every save because io_saveFile() itself
286        // acquires the same lock internally.
287        $lock = $file . '.lock';
288        io_lock($lock);
289
290        $data    = $this->loadUserData($user);
291        $now     = time();
292        $changed = false;
293
294        foreach ($values as $key => $value) {
295            if (!isset($toggles[$key])) {
296                continue; // not a registered toggle — ignore
297            }
298            $clean = $this->sanitiseValue($toggles[$key], $value);
299            if ($clean === null) {
300                continue; // invalid value for this toggle — ignore
301            }
302            // no-op write avoidance: skip if the value is unchanged
303            if (isset($data[$key]) && $data[$key]['value'] === $clean) {
304                continue;
305            }
306            $data[$key] = [
307                'value'      => $clean,
308                'changed_at' => $now,
309                'changed_by' => $actor,
310            ];
311            $changed = true;
312        }
313
314        if (!$changed) {
315            io_unlock($lock);
316            return true; // nothing to write — treated as success
317        }
318
319        $ok = io_saveFile($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
320        io_unlock($lock);
321        return (bool) $ok;
322    }
323
324    /**
325     * Coerce a submitted value to a valid value for a toggle.
326     *
327     * @param array $toggle  a normalised toggle definition
328     * @param mixed $value
329     * @return mixed|null  the clean value, or null if it cannot be used
330     */
331    protected function sanitiseValue(array $toggle, $value)
332    {
333        if (!is_scalar($value)) {
334            return null;
335        }
336        if ($toggle['type'] === 'select') {
337            return array_key_exists($value, $toggle['options']) ? $value : null;
338        }
339        // checkbox
340        return empty($value) ? 0 : 1;
341    }
342}
343