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