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