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