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