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