1<?php 2 3/** 4 * User Settings plugin — action component. 5 * 6 * Provides three things: 7 * 8 * 1. A "Preferences" item in the user menu, placed just before "Update 9 * Profile" (via the MENU_ITEMS_ASSEMBLY event — template-independent). 10 * 11 * 2. A custom action, do=usersettings, claimed in ACTION_ACT_PREPROCESS and 12 * rendered in TPL_ACT_UNKNOWN. This is the documented way for a plugin to 13 * own a do= value: preventing the preprocess default makes DokuWiki route 14 * the action through dokuwiki\Action\Plugin, which fires TPL_ACT_UNKNOWN. 15 * 16 * 3. The settings page itself: a plain HTML form of every registered toggle, 17 * with Post/Redirect/Get handling that saves through the helper. 18 */ 19 20// must be run within DokuWiki 21if (!defined('DOKU_INC')) die(); 22 23use dokuwiki\Extension\ActionPlugin; 24use dokuwiki\Extension\EventHandler; 25use dokuwiki\Extension\Event; 26 27class action_plugin_usersettings extends ActionPlugin 28{ 29 /** the do= value this plugin owns */ 30 const ACTION = 'usersettings'; 31 32 /** 33 * Register event handlers. 34 */ 35 public function register(EventHandler $controller) 36 { 37 $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly'); 38 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess'); 39 $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown'); 40 41 // Register the built-in interface language toggle. 42 $controller->register_hook( 43 helper_plugin_usersettings::REGISTER_EVENT, 44 'BEFORE', 45 $this, 46 'registerLangToggle' 47 ); 48 49 // Apply the user's language choice as early as possible so that all 50 // DokuWiki rendering — including TPL_ hooks further down the chain — 51 // uses the right language strings. ACTION_ACT_PREPROCESS fires before 52 // any output is produced and before template rendering begins. 53 $controller->register_hook( 54 'ACTION_ACT_PREPROCESS', 55 'BEFORE', 56 $this, 57 'applyUserLang', 58 null, 59 // run at priority -10 so we fire before handlePreprocess (0) and 60 // before anything else that might read $conf['lang'] 61 -10 62 ); 63 } 64 65 /** 66 * Load the storage/registration helper. 67 * 68 * @return helper_plugin_usersettings|null 69 */ 70 protected function getHelper() 71 { 72 /** @var helper_plugin_usersettings|null $helper */ 73 $helper = plugin_load('helper', 'usersettings'); 74 return $helper; 75 } 76 77 // --------------------------------------------------------------------- 78 // 1. The user-menu item 79 // --------------------------------------------------------------------- 80 81 /** 82 * Insert the "Preferences" item into the user menu, just before the 83 * "Update Profile" item. 84 * 85 * @param Event $event MENU_ITEMS_ASSEMBLY 86 * @param mixed $param 87 */ 88 public function handleMenuAssembly(Event $event, $param) 89 { 90 if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') { 91 return; 92 } 93 94 try { 95 $item = new \dokuwiki\plugin\usersettings\MenuItem(); 96 } catch (\RuntimeException $e) { 97 // anonymous visitor, or the action is disabled — no menu item 98 return; 99 } 100 101 if (!isset($event->data['items']) || !is_array($event->data['items'])) { 102 return; 103 } 104 $items =& $event->data['items']; 105 106 // find the Profile item; default to appending if it is not present 107 $pos = count($items); 108 foreach ($items as $i => $existing) { 109 if ($existing instanceof \dokuwiki\Menu\Item\Profile) { 110 $pos = $i; 111 break; 112 } 113 } 114 array_splice($items, $pos, 0, [$item]); 115 } 116 117 // --------------------------------------------------------------------- 118 // 2. Claiming the custom action + handling the save 119 // --------------------------------------------------------------------- 120 121 /** 122 * Claim do=usersettings and, on a form submission, save and redirect. 123 * 124 * @param Event $event ACTION_ACT_PREPROCESS 125 * @param mixed $param 126 */ 127 public function handlePreprocess(Event $event, $param) 128 { 129 if ($event->data !== self::ACTION) { 130 return; 131 } 132 133 // Preventing the default makes DokuWiki keep the action and route it 134 // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN. 135 $event->preventDefault(); 136 $event->stopPropagation(); 137 138 global $INPUT, $ID; 139 140 $user = $INPUT->server->str('REMOTE_USER'); 141 if ($user === '') { 142 return; // anonymous — the rendered page shows a login notice 143 } 144 145 // not a save submission — nothing to do, the page will just render 146 if (!$INPUT->post->bool('usersettings_save')) { 147 return; 148 } 149 150 // CSRF protection; checkSecurityToken() shows its own error on failure 151 if (!checkSecurityToken()) { 152 return; 153 } 154 155 $ok = $this->saveSubmittedPreferences($user); 156 msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1); 157 158 // Post/Redirect/Get: a refresh must not re-submit the form 159 send_redirect(wl($ID, ['do' => self::ACTION], true, '&')); 160 } 161 162 /** 163 * Read the submitted toggle values for every registered toggle and store 164 * them for the given user. 165 * 166 * Kept separate from handlePreprocess() so it carries no redirect and can 167 * be exercised directly by tests. Checkboxes that are unchecked do not 168 * appear in the POST data, so every registered toggle is read explicitly 169 * rather than iterating whatever was submitted. 170 * 171 * @param string $user whose preferences are being written 172 * @param string|null $actor who is making the change; defaults to $user 173 * (the admin component passes the admin here) 174 * @return bool 175 */ 176 public function saveSubmittedPreferences($user, $actor = null) 177 { 178 global $INPUT; 179 180 if ($actor === null) { 181 $actor = $user; 182 } 183 184 $helper = $this->getHelper(); 185 if ($helper === null) { 186 return false; 187 } 188 189 $values = []; 190 foreach ($helper->getRegisteredToggles() as $key => $def) { 191 if ($def['type'] === 'checkbox') { 192 $values[$key] = $INPUT->post->bool($key) ? 1 : 0; 193 } else { 194 $values[$key] = $INPUT->post->str($key); 195 } 196 } 197 198 return $helper->setPreferences($values, $user, $actor); 199 } 200 201 // --------------------------------------------------------------------- 202 // 3. Rendering the settings page 203 // --------------------------------------------------------------------- 204 205 /** 206 * Render the settings page for do=usersettings. 207 * 208 * @param Event $event TPL_ACT_UNKNOWN 209 * @param mixed $param 210 */ 211 public function handleUnknown(Event $event, $param) 212 { 213 if ($event->data !== self::ACTION) { 214 return; 215 } 216 $event->preventDefault(); 217 $event->stopPropagation(); 218 219 echo $this->renderSettingsPage(); 220 } 221 222 /** 223 * Build the HTML of the settings page. 224 * 225 * @return string 226 */ 227 public function renderSettingsPage() 228 { 229 global $INPUT, $ID; 230 231 $user = $INPUT->server->str('REMOTE_USER'); 232 233 $html = '<div class="plugin_usersettings">'; 234 $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 235 236 if ($user === '') { 237 $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>'; 238 return $html . '</div>'; 239 } 240 241 $helper = $this->getHelper(); 242 $toggles = $helper ? $helper->getRegisteredToggles() : []; 243 244 if (empty($toggles)) { 245 $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 246 return $html . '</div>'; 247 } 248 249 $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>'; 250 251 $action = wl($ID, ['do' => self::ACTION], false, '&'); 252 $html .= '<form method="post" action="' . $action . '" class="us-form">'; 253 $html .= formSecurityToken(false); 254 255 foreach ($toggles as $key => $def) { 256 $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user)); 257 } 258 259 $html .= '<div class="us-actions">'; 260 $html .= '<button type="submit" name="usersettings_save" value="1" class="button">' 261 . hsc($this->getLang('save')) . '</button>'; 262 $html .= '</div>'; 263 $html .= '</form>'; 264 265 return $html . '</div>'; 266 } 267 268 // --------------------------------------------------------------------- 269 // Built-in: interface language toggle 270 // --------------------------------------------------------------------- 271 272 /** 273 * Contribute the "Interface language" select to the usersettings registry. 274 * 275 * The option list is built by scanning DOKU_INC/inc/lang/ for sub- 276 * directories that contain a lang.php file — the same source the 277 * Configuration Manager uses for its own language drop-down. The scan 278 * result is sorted alphabetically by language code; the site default is 279 * used as the toggle's default value so the toggle appears pre-selected 280 * correctly for users who have never changed it. 281 * 282 * @param Event $event PLUGIN_USERSETTINGS_REGISTER 283 * @param mixed $param 284 */ 285 public function registerLangToggle(Event $event, $param) 286 { 287 global $conf; 288 289 $options = $this->getAvailableLanguages(); 290 if (empty($options)) { 291 return; // nothing to register if we cannot list languages 292 } 293 294 $siteDefault = $conf['lang'] ?? 'en'; 295 if (!array_key_exists($siteDefault, $options)) { 296 $siteDefault = array_key_first($options); 297 } 298 299 $event->data[] = [ 300 'key' => 'lang', 301 'label' => $this->getLang('lang_label'), 302 'desc' => $this->getLang('lang_desc'), 303 'type' => 'select', 304 'options' => $options, 305 'default' => $siteDefault, 306 'plugin' => 'usersettings', 307 ]; 308 } 309 310 /** 311 * Build the [code => display name] map of all installed DokuWiki interface 312 * languages by scanning inc/lang/. The display name is the language's own 313 * native name (endonym), falling back to the bare code for any language not 314 * in the built-in map. 315 * 316 * @return array [langCode => endonym] sorted by language code 317 */ 318 protected function getAvailableLanguages() 319 { 320 $pattern = DOKU_INC . 'inc/lang/*/lang.php'; 321 $files = glob($pattern); 322 if ($files === false || empty($files)) { 323 return []; 324 } 325 326 $langs = []; 327 foreach ($files as $file) { 328 $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php" 329 if ($code === '' || $code === '.' || $code === '..') { 330 continue; 331 } 332 $langs[$code] = $this->languageName($code); 333 } 334 335 ksort($langs, SORT_STRING); 336 return $langs; 337 } 338 339 /** 340 * Return the native name (endonym) for a language code. 341 * Falls back to the bare code for languages not in the built-in map. 342 * 343 * @param string $code ISO language code as used by DokuWiki 344 * @return string 345 */ 346 protected function languageName($code) 347 { 348 $names = [ 349 'af' => 'Afrikaans', 350 'ar' => 'العربية', 351 'az' => 'Azərbaycan', 352 'be' => 'Беларуская', 353 'bg' => 'Български', 354 'bn' => 'বাংলা', 355 'br' => 'Brezhoneg', 356 'ca' => 'Català', 357 'ca-valencia' => 'Català (Valencià)', 358 'ckb' => 'کوردی سۆرانی', 359 'cs' => 'Čeština', 360 'cy' => 'Cymraeg', 361 'da' => 'Dansk', 362 'de' => 'Deutsch', 363 'de-informal' => 'Deutsch (informell)', 364 'el' => 'Ελληνικά', 365 'en' => 'English', 366 'eo' => 'Esperanto', 367 'es' => 'Español', 368 'et' => 'Eesti', 369 'eu' => 'Euskara', 370 'fa' => 'فارسی', 371 'fi' => 'Suomi', 372 'fo' => 'Føroyskt', 373 'fr' => 'Français', 374 'fy' => 'Frysk', 375 'gl' => 'Galego', 376 'he' => 'עברית', 377 'hi' => 'हिन्दी', 378 'hr' => 'Hrvatski', 379 'hu' => 'Magyar', 380 'hu-formal' => 'Magyar (magázó)', 381 'hy' => 'Հայերեն', 382 'ia' => 'Interlingua', 383 'id' => 'Bahasa Indonesia', 384 'id-ni' => 'Bahasa Indonesia (NTT)', 385 'is' => 'Íslenska', 386 'it' => 'Italiano', 387 'ja' => '日本語', 388 'ka' => 'ქართული', 389 'kk' => 'Қазақша', 390 'km' => 'ភាសាខ្មែរ', 391 'kn' => 'ಕನ್ನಡ', 392 'ko' => '한국어', 393 'ku' => 'Kurdî', 394 'la' => 'Latina', 395 'lb' => 'Lëtzebuergesch', 396 'lt' => 'Lietuvių', 397 'lv' => 'Latviešu', 398 'mg' => 'Malagasy', 399 'mk' => 'Македонски', 400 'ml' => 'മലയാളം', 401 'mr' => 'मराठी', 402 'ms' => 'Bahasa Melayu', 403 'my' => 'မြန်မာ', 404 'nan' => '閩南語', 405 'nb' => 'Norsk bokmål', 406 'ne' => 'नेपाली', 407 'nl' => 'Nederlands', 408 'nn' => 'Nynorsk', 409 'no' => 'Norsk', 410 'oc' => 'Occitan', 411 'pl' => 'Polski', 412 'pt' => 'Português', 413 'pt-br' => 'Português brasileiro', 414 'ro' => 'Română', 415 'ru' => 'Русский', 416 'si' => 'සිංහල', 417 'sk' => 'Slovenčina', 418 'sl' => 'Slovenščina', 419 'sq' => 'Shqip', 420 'sr' => 'Српски', 421 'sv' => 'Svenska', 422 'sw' => 'Kiswahili', 423 'ta' => 'தமிழ்', 424 'te' => 'తెలుగు', 425 'th' => 'ภาษาไทย', 426 'tr' => 'Türkçe', 427 'uk' => 'Українська', 428 'ur' => 'اردو', 429 'uz' => 'Oʻzbekcha', 430 'vi' => 'Tiếng Việt', 431 'zh' => '中文 (简体)', 432 'zh-tw' => '中文 (繁體)', 433 ]; 434 435 return $names[$code] ?? $code; 436 } 437 438 /** 439 * Apply the logged-in user's preferred interface language, overriding the 440 * site-wide $conf['lang'] before any rendering takes place. 441 * 442 * DokuWiki loads language strings lazily (via getLang() / $lang global 443 * reloads triggered by calls to init_lang()), so changing $conf['lang'] 444 * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient 445 * to affect all subsequent output, including this plugin's own chrome. 446 * 447 * We read the stored record directly via getRecord() rather than routing 448 * through getPreference(). getPreference() fires PLUGIN_USERSETTINGS_REGISTER 449 * (and the full inc/lang/ glob) on every logged-in request before the 450 * language is known. Reading the raw record avoids that overhead and, 451 * crucially, means the toggle registry is built *after* $conf['lang'] has 452 * been updated — so toggle labels resolve in the user's chosen language. 453 * 454 * No-op for anonymous visitors or when the user has not chosen a language 455 * that differs from the site default. 456 * 457 * @param Event $event ACTION_ACT_PREPROCESS 458 * @param mixed $param 459 */ 460 public function applyUserLang(Event $event, $param) 461 { 462 global $conf, $INPUT; 463 464 $user = $INPUT->server->str('REMOTE_USER'); 465 if ($user === '') { 466 return; // anonymous — use the site default 467 } 468 469 $helper = $this->getHelper(); 470 if ($helper === null) { 471 return; 472 } 473 474 // Read the raw stored record — does not fire PLUGIN_USERSETTINGS_REGISTER. 475 $record = $helper->getRecord('lang', $user); 476 $preferred = (is_array($record) && isset($record['value'])) ? (string) $record['value'] : null; 477 478 if ($preferred === null || $preferred === '' || $preferred === $conf['lang']) { 479 return; // no preference stored or already correct 480 } 481 482 // Defence-in-depth: language codes are lowercase [a-z0-9-] only. 483 if (!preg_match('/^[a-z0-9-]+$/', $preferred)) { 484 return; 485 } 486 487 // Validate: only apply if the directory actually exists to avoid a 488 // broken page when someone stores a stale language code. 489 if (!is_dir(DOKU_INC . 'inc/lang/' . $preferred)) { 490 return; 491 } 492 493 $conf['lang'] = $preferred; 494 495 // Re-initialise the global $lang array so immediately-following 496 // getLang() calls within this request pick up the new language. 497 init_lang($preferred); 498 } 499 500 // --------------------------------------------------------------------- 501 // Form rendering (shared between action and admin) 502 // --------------------------------------------------------------------- 503 504 /** 505 * Render one toggle as a form row. Public so the admin component can 506 * reuse it for its per-user edit form. 507 * 508 * @param array $def a normalised toggle definition 509 * @param mixed $value the user's effective value for this toggle 510 * @return string 511 */ 512 public function renderToggleRow(array $def, $value) 513 { 514 $key = hsc($def['key']); 515 516 if ($def['type'] === 'select') { 517 $id = 'us__' . $key; 518 $html = '<div class="us-row us-row-select">'; 519 $html .= '<label class="us-label" for="' . $id . '">'; 520 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 521 $html .= '<select name="' . $key . '" id="' . $id . '">'; 522 foreach ($def['options'] as $optValue => $optLabel) { 523 $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : ''; 524 $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>' 525 . hsc((string) $optLabel) . '</option>'; 526 } 527 $html .= '</select>'; 528 $html .= '</label>'; 529 } else { 530 $checked = empty($value) ? '' : ' checked="checked"'; 531 $html = '<div class="us-row us-row-checkbox">'; 532 $html .= '<label class="us-label">'; 533 $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />'; 534 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 535 $html .= '</label>'; 536 } 537 538 if ($def['desc'] !== '') { 539 $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>'; 540 } 541 542 return $html . '</div>'; 543 } 544} 545