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