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