xref: /plugin/usersettings/helper.php (revision 49b74e0a20d271d13e295d5f68707f57e70072a5)
11ab40613Stracker-user<?php
21ab40613Stracker-user
31ab40613Stracker-user/**
41ab40613Stracker-user * User Settings plugin — storage and registration helper.
51ab40613Stracker-user *
61ab40613Stracker-user * This component owns three things:
71ab40613Stracker-user *
81ab40613Stracker-user *   1. The per-user preference store. One JSON file per user under the meta
91ab40613Stracker-user *      directory, holding {key => {value, changed_at, changed_by}}. JSON is
101ab40613Stracker-user *      used deliberately so the files are human-readable and easy to inspect
111ab40613Stracker-user *      or back up.
121ab40613Stracker-user *
131ab40613Stracker-user *   2. The PLUGIN_USERSETTINGS_REGISTER event. Other plugins (and template
141ab40613Stracker-user *      companion plugins) hook it to declare their own toggles, so this
151ab40613Stracker-user *      plugin never needs editing when a new toggle is added elsewhere.
161ab40613Stracker-user *
171ab40613Stracker-user *   3. The read/write API used by this plugin's own settings page and admin
181ab40613Stracker-user *      table, and by feature plugins that want to read a user's preference.
191ab40613Stracker-user *
201ab40613Stracker-user * Access control is intentionally NOT enforced here — this is a storage
211ab40613Stracker-user * primitive. Callers are responsible: the settings page only ever writes the
221ab40613Stracker-user * current user's own file, and the admin component is gated to admins by
231ab40613Stracker-user * DokuWiki's admin dispatcher.
241ab40613Stracker-user */
251ab40613Stracker-user
261ab40613Stracker-user// must be run within DokuWiki
271ab40613Stracker-userif (!defined('DOKU_INC')) die();
281ab40613Stracker-user
29*49b74e0aStracker-useruse dokuwiki\Extension\Plugin;
30*49b74e0aStracker-user
31*49b74e0aStracker-userclass helper_plugin_usersettings extends Plugin
321ab40613Stracker-user{
331ab40613Stracker-user    /** Event other plugins hook to declare their toggles. */
341ab40613Stracker-user    const REGISTER_EVENT = 'PLUGIN_USERSETTINGS_REGISTER';
351ab40613Stracker-user
361ab40613Stracker-user    /** @var array|null cached, normalised toggle definitions for this request */
371ab40613Stracker-user    protected $toggles = null;
381ab40613Stracker-user
391ab40613Stracker-user    // ---------------------------------------------------------------------
401ab40613Stracker-user    //  Storage location
411ab40613Stracker-user    // ---------------------------------------------------------------------
421ab40613Stracker-user
431ab40613Stracker-user    /**
441ab40613Stracker-user     * Directory holding the per-user preference files.
451ab40613Stracker-user     *
461ab40613Stracker-user     * Lives under the meta directory: persistent (unlike the cache dir) and,
471ab40613Stracker-user     * because it is inside data/, not served by the web server.
481ab40613Stracker-user     *
491ab40613Stracker-user     * @return string absolute path (created if missing)
501ab40613Stracker-user     */
511ab40613Stracker-user    public function getStorePath()
521ab40613Stracker-user    {
531ab40613Stracker-user        global $conf;
541ab40613Stracker-user        $path = $conf['metadir'] . '/usersettings';
551ab40613Stracker-user        io_mkdir_p($path);
561ab40613Stracker-user        return $path;
571ab40613Stracker-user    }
581ab40613Stracker-user
591ab40613Stracker-user    /**
601ab40613Stracker-user     * Absolute path of one user's preference file. The username is
611ab40613Stracker-user     * rawurlencoded so any character is safe in a filename.
621ab40613Stracker-user     *
631ab40613Stracker-user     * @param string $user
641ab40613Stracker-user     * @return string
651ab40613Stracker-user     */
661ab40613Stracker-user    protected function getUserFile($user)
671ab40613Stracker-user    {
681ab40613Stracker-user        return $this->getStorePath() . '/' . rawurlencode($user) . '.json';
691ab40613Stracker-user    }
701ab40613Stracker-user
711ab40613Stracker-user    // ---------------------------------------------------------------------
721ab40613Stracker-user    //  Raw per-user data
731ab40613Stracker-user    // ---------------------------------------------------------------------
741ab40613Stracker-user
751ab40613Stracker-user    /**
761ab40613Stracker-user     * Load one user's stored preferences.
771ab40613Stracker-user     *
781ab40613Stracker-user     * @param string $user
791ab40613Stracker-user     * @return array  [key => ['value'=>mixed, 'changed_at'=>int, 'changed_by'=>string]]
801ab40613Stracker-user     *                empty array if the user has no stored preferences
811ab40613Stracker-user     */
821ab40613Stracker-user    public function loadUserData($user)
831ab40613Stracker-user    {
841ab40613Stracker-user        $file = $this->getUserFile($user);
851ab40613Stracker-user        if (!file_exists($file)) {
861ab40613Stracker-user            return [];
871ab40613Stracker-user        }
881ab40613Stracker-user        $raw = io_readFile($file, false);
891ab40613Stracker-user        if ($raw === '') {
901ab40613Stracker-user            return [];
911ab40613Stracker-user        }
921ab40613Stracker-user        $data = json_decode($raw, true);
931ab40613Stracker-user        return is_array($data) ? $data : [];
941ab40613Stracker-user    }
951ab40613Stracker-user
961ab40613Stracker-user    // ---------------------------------------------------------------------
971ab40613Stracker-user    //  Registered toggles
981ab40613Stracker-user    // ---------------------------------------------------------------------
991ab40613Stracker-user
1001ab40613Stracker-user    /**
1011ab40613Stracker-user     * Collect the toggle definitions contributed by other plugins.
1021ab40613Stracker-user     *
1031ab40613Stracker-user     * Fires PLUGIN_USERSETTINGS_REGISTER; handlers append definition arrays
1041ab40613Stracker-user     * to the event data. Each definition is validated and normalised;
1051ab40613Stracker-user     * unusable ones are dropped, and a key registered more than once keeps
1061ab40613Stracker-user     * its first registration. Cached for the duration of the request.
1071ab40613Stracker-user     *
1081ab40613Stracker-user     * @return array  [key => normalised definition]
1091ab40613Stracker-user     */
1101ab40613Stracker-user    public function getRegisteredToggles()
1111ab40613Stracker-user    {
1121ab40613Stracker-user        if ($this->toggles !== null) {
1131ab40613Stracker-user            return $this->toggles;
1141ab40613Stracker-user        }
1151ab40613Stracker-user
1161ab40613Stracker-user        $raw = [];
1171ab40613Stracker-user        \dokuwiki\Extension\Event::createAndTrigger(self::REGISTER_EVENT, $raw);
1181ab40613Stracker-user
1191ab40613Stracker-user        $toggles = [];
1201ab40613Stracker-user        foreach ((array) $raw as $def) {
1211ab40613Stracker-user            $def = $this->normaliseDefinition($def);
1221ab40613Stracker-user            if ($def === null) {
1231ab40613Stracker-user                continue; // invalid — skip
1241ab40613Stracker-user            }
1251ab40613Stracker-user            if (isset($toggles[$def['key']])) {
1261ab40613Stracker-user                continue; // duplicate key — first registration wins
1271ab40613Stracker-user            }
1281ab40613Stracker-user            $toggles[$def['key']] = $def;
1291ab40613Stracker-user        }
1301ab40613Stracker-user
1311ab40613Stracker-user        $this->toggles = $toggles;
1321ab40613Stracker-user        return $toggles;
1331ab40613Stracker-user    }
1341ab40613Stracker-user
1351ab40613Stracker-user    /**
1361ab40613Stracker-user     * Get one toggle definition by key.
1371ab40613Stracker-user     *
1381ab40613Stracker-user     * @param string $key
1391ab40613Stracker-user     * @return array|null
1401ab40613Stracker-user     */
1411ab40613Stracker-user    public function getToggle($key)
1421ab40613Stracker-user    {
1431ab40613Stracker-user        $toggles = $this->getRegisteredToggles();
1441ab40613Stracker-user        return $toggles[$key] ?? null;
1451ab40613Stracker-user    }
1461ab40613Stracker-user
1471ab40613Stracker-user    /**
1481ab40613Stracker-user     * Validate and normalise one toggle definition supplied by a registering
1491ab40613Stracker-user     * plugin. Returns a clean definition with every expected field present,
1501ab40613Stracker-user     * or null if the definition is unusable.
1511ab40613Stracker-user     *
1521ab40613Stracker-user     * Expected input fields:
1531ab40613Stracker-user     *   key      (string, required)  unique identifier; [A-Za-z0-9_] only
1541ab40613Stracker-user     *   label    (string, required)  shown on the settings page / admin table
1551ab40613Stracker-user     *   type     (string)            'checkbox' (default) or 'select'
1561ab40613Stracker-user     *   default  (mixed)             default value
1571ab40613Stracker-user     *   options  (array)             value=>label map; required for 'select'
1581ab40613Stracker-user     *   desc     (string, optional)  help text
1591ab40613Stracker-user     *   plugin   (string, optional)  id of the registering plugin/template
1601ab40613Stracker-user     *
1611ab40613Stracker-user     * @param mixed $def
1621ab40613Stracker-user     * @return array|null
1631ab40613Stracker-user     */
1641ab40613Stracker-user    protected function normaliseDefinition($def)
1651ab40613Stracker-user    {
1661ab40613Stracker-user        if (!is_array($def)) return null;
1671ab40613Stracker-user        if (empty($def['key']) || !is_string($def['key'])) return null;
1681ab40613Stracker-user        if (empty($def['label']) || !is_string($def['label'])) return null;
1691ab40613Stracker-user
1701ab40613Stracker-user        // the key is used as a storage key and an HTML form field name
1711ab40613Stracker-user        if (!preg_match('/^[A-Za-z0-9_]+$/', $def['key'])) return null;
1721ab40613Stracker-user
1731ab40613Stracker-user        $type = $def['type'] ?? 'checkbox';
1741ab40613Stracker-user        if (!in_array($type, ['checkbox', 'select'], true)) {
1751ab40613Stracker-user            $type = 'checkbox';
1761ab40613Stracker-user        }
1771ab40613Stracker-user
1781ab40613Stracker-user        $clean = [
1791ab40613Stracker-user            'key'    => $def['key'],
1801ab40613Stracker-user            'label'  => $def['label'],
1811ab40613Stracker-user            'type'   => $type,
1821ab40613Stracker-user            'desc'   => (isset($def['desc']) && is_string($def['desc'])) ? $def['desc'] : '',
1831ab40613Stracker-user            'plugin' => (isset($def['plugin']) && is_string($def['plugin'])) ? $def['plugin'] : '',
1841ab40613Stracker-user        ];
1851ab40613Stracker-user
1861ab40613Stracker-user        if ($type === 'select') {
1871ab40613Stracker-user            // a select needs a non-empty value=>label option map
1881ab40613Stracker-user            if (empty($def['options']) || !is_array($def['options'])) {
1891ab40613Stracker-user                return null;
1901ab40613Stracker-user            }
1911ab40613Stracker-user            $clean['options'] = $def['options'];
1921ab40613Stracker-user            // the default must be one of the option keys
1931ab40613Stracker-user            $default = $def['default'] ?? null;
1941ab40613Stracker-user            if ($default === null || !array_key_exists($default, $def['options'])) {
1951ab40613Stracker-user                $default = array_key_first($def['options']);
1961ab40613Stracker-user            }
1971ab40613Stracker-user            $clean['default'] = $default;
1981ab40613Stracker-user        } else {
1991ab40613Stracker-user            // checkbox: default coerced to a clean 0/1
2001ab40613Stracker-user            $clean['default'] = empty($def['default']) ? 0 : 1;
2011ab40613Stracker-user        }
2021ab40613Stracker-user
2031ab40613Stracker-user        return $clean;
2041ab40613Stracker-user    }
2051ab40613Stracker-user
2061ab40613Stracker-user    // ---------------------------------------------------------------------
2071ab40613Stracker-user    //  Read API
2081ab40613Stracker-user    // ---------------------------------------------------------------------
2091ab40613Stracker-user
2101ab40613Stracker-user    /**
2111ab40613Stracker-user     * The effective value of a preference for a user: the value the user has
2121ab40613Stracker-user     * explicitly stored, or — if they never set one — the toggle's registered
2131ab40613Stracker-user     * default. This is the method feature plugins call.
2141ab40613Stracker-user     *
2151ab40613Stracker-user     * @param string      $key
2161ab40613Stracker-user     * @param string|null $user  defaults to the current user
2171ab40613Stracker-user     * @return mixed  the value, or null if $key is not a registered toggle
2181ab40613Stracker-user     */
2191ab40613Stracker-user    public function getPreference($key, $user = null)
2201ab40613Stracker-user    {
2211ab40613Stracker-user        $toggle = $this->getToggle($key);
2221ab40613Stracker-user        if ($toggle === null) {
2231ab40613Stracker-user            return null; // unknown toggle
2241ab40613Stracker-user        }
2251ab40613Stracker-user
2261ab40613Stracker-user        if ($user === null) {
2271ab40613Stracker-user            global $INPUT;
2281ab40613Stracker-user            $user = $INPUT->server->str('REMOTE_USER');
2291ab40613Stracker-user        }
2301ab40613Stracker-user        if ($user === '') {
2311ab40613Stracker-user            return $toggle['default']; // anonymous — no stored preferences
2321ab40613Stracker-user        }
2331ab40613Stracker-user
2341ab40613Stracker-user        $data = $this->loadUserData($user);
2351ab40613Stracker-user        if (isset($data[$key]) && array_key_exists('value', $data[$key])) {
2361ab40613Stracker-user            return $data[$key]['value'];
2371ab40613Stracker-user        }
2381ab40613Stracker-user        return $toggle['default'];
2391ab40613Stracker-user    }
2401ab40613Stracker-user
2411ab40613Stracker-user    /**
2421ab40613Stracker-user     * The stored record for one key (value + changed_at + changed_by), or null
2431ab40613Stracker-user     * if the user has no explicit value for it. Used by the admin table to
2441ab40613Stracker-user     * show who changed a setting and when.
2451ab40613Stracker-user     *
2461ab40613Stracker-user     * @param string $key
2471ab40613Stracker-user     * @param string $user
2481ab40613Stracker-user     * @return array|null
2491ab40613Stracker-user     */
2501ab40613Stracker-user    public function getRecord($key, $user)
2511ab40613Stracker-user    {
2521ab40613Stracker-user        $data = $this->loadUserData($user);
2531ab40613Stracker-user        return $data[$key] ?? null;
2541ab40613Stracker-user    }
2551ab40613Stracker-user
2561ab40613Stracker-user    // ---------------------------------------------------------------------
2571ab40613Stracker-user    //  Write API
2581ab40613Stracker-user    // ---------------------------------------------------------------------
2591ab40613Stracker-user
2601ab40613Stracker-user    /**
2611ab40613Stracker-user     * Write one or more preferences for a user.
2621ab40613Stracker-user     *
2631ab40613Stracker-user     * Each value is validated against its registered toggle definition:
2641ab40613Stracker-user     * unknown keys are ignored, checkbox values coerced to 0/1, and select
2651ab40613Stracker-user     * values must be a defined option. The timestamp and the actor (whoever
2661ab40613Stracker-user     * is making the change — the user themselves, or an admin) are recorded.
2671ab40613Stracker-user     *
2681ab40613Stracker-user     * @param array  $values  [key => value]
2691ab40613Stracker-user     * @param string $user    whose preferences are being written
2701ab40613Stracker-user     * @param string $actor   who is making the change
2711ab40613Stracker-user     * @return bool  true on success (also true when there was nothing to change)
2721ab40613Stracker-user     */
2731ab40613Stracker-user    public function setPreferences(array $values, $user, $actor)
2741ab40613Stracker-user    {
2751ab40613Stracker-user        if ($user === '' || $user === null || $actor === '' || $actor === null) {
2761ab40613Stracker-user            return false;
2771ab40613Stracker-user        }
2781ab40613Stracker-user
2791ab40613Stracker-user        $toggles = $this->getRegisteredToggles();
2801ab40613Stracker-user        $file    = $this->getUserFile($user);
2811ab40613Stracker-user
282*49b74e0aStracker-user        // Use a distinct lock key so our read-modify-write cycle serialises
283*49b74e0aStracker-user        // correctly without colliding with io_saveFile()'s own internal lock on
284*49b74e0aStracker-user        // the same file.  Wrapping io_saveFile() in io_lock($file)/io_unlock($file)
285*49b74e0aStracker-user        // would busy-wait 3 seconds every save because io_saveFile() itself
286*49b74e0aStracker-user        // acquires the same lock internally.
287*49b74e0aStracker-user        $lock = $file . '.lock';
288*49b74e0aStracker-user        io_lock($lock);
2891ab40613Stracker-user
2901ab40613Stracker-user        $data    = $this->loadUserData($user);
2911ab40613Stracker-user        $now     = time();
2921ab40613Stracker-user        $changed = false;
2931ab40613Stracker-user
2941ab40613Stracker-user        foreach ($values as $key => $value) {
2951ab40613Stracker-user            if (!isset($toggles[$key])) {
2961ab40613Stracker-user                continue; // not a registered toggle — ignore
2971ab40613Stracker-user            }
2981ab40613Stracker-user            $clean = $this->sanitiseValue($toggles[$key], $value);
2991ab40613Stracker-user            if ($clean === null) {
3001ab40613Stracker-user                continue; // invalid value for this toggle — ignore
3011ab40613Stracker-user            }
3021ab40613Stracker-user            // no-op write avoidance: skip if the value is unchanged
3031ab40613Stracker-user            if (isset($data[$key]) && $data[$key]['value'] === $clean) {
3041ab40613Stracker-user                continue;
3051ab40613Stracker-user            }
3061ab40613Stracker-user            $data[$key] = [
3071ab40613Stracker-user                'value'      => $clean,
3081ab40613Stracker-user                'changed_at' => $now,
3091ab40613Stracker-user                'changed_by' => $actor,
3101ab40613Stracker-user            ];
3111ab40613Stracker-user            $changed = true;
3121ab40613Stracker-user        }
3131ab40613Stracker-user
3141ab40613Stracker-user        if (!$changed) {
315*49b74e0aStracker-user            io_unlock($lock);
3161ab40613Stracker-user            return true; // nothing to write — treated as success
3171ab40613Stracker-user        }
3181ab40613Stracker-user
319f51fe07cStracker-user        $ok = io_saveFile($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
320*49b74e0aStracker-user        io_unlock($lock);
3211ab40613Stracker-user        return (bool) $ok;
3221ab40613Stracker-user    }
3231ab40613Stracker-user
3241ab40613Stracker-user    /**
3251ab40613Stracker-user     * Coerce a submitted value to a valid value for a toggle.
3261ab40613Stracker-user     *
3271ab40613Stracker-user     * @param array $toggle  a normalised toggle definition
3281ab40613Stracker-user     * @param mixed $value
3291ab40613Stracker-user     * @return mixed|null  the clean value, or null if it cannot be used
3301ab40613Stracker-user     */
3311ab40613Stracker-user    protected function sanitiseValue(array $toggle, $value)
3321ab40613Stracker-user    {
3331ab40613Stracker-user        if (!is_scalar($value)) {
3341ab40613Stracker-user            return null;
3351ab40613Stracker-user        }
3361ab40613Stracker-user        if ($toggle['type'] === 'select') {
3371ab40613Stracker-user            return array_key_exists($value, $toggle['options']) ? $value : null;
3381ab40613Stracker-user        }
3391ab40613Stracker-user        // checkbox
3401ab40613Stracker-user        return empty($value) ? 0 : 1;
3411ab40613Stracker-user    }
3421ab40613Stracker-user}
343