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