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 23class action_plugin_usersettings extends DokuWiki_Action_Plugin 24{ 25 /** the do= value this plugin owns */ 26 const ACTION = 'usersettings'; 27 28 /** 29 * Register event handlers. 30 */ 31 public function register(Doku_Event_Handler $controller) 32 { 33 $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly'); 34 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess'); 35 $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown'); 36 37 // Register the built-in interface language toggle. 38 $controller->register_hook( 39 helper_plugin_usersettings::REGISTER_EVENT, 40 'BEFORE', 41 $this, 42 'registerLangToggle' 43 ); 44 45 // Apply the user's language choice as early as possible so that all 46 // DokuWiki rendering — including TPL_ hooks further down the chain — 47 // uses the right language strings. ACTION_ACT_PREPROCESS fires before 48 // any output is produced and before template rendering begins. 49 $controller->register_hook( 50 'ACTION_ACT_PREPROCESS', 51 'BEFORE', 52 $this, 53 'applyUserLang', 54 null, 55 // run at priority -10 so we fire before handlePreprocess (0) and 56 // before anything else that might read $conf['lang'] 57 -10 58 ); 59 } 60 61 /** 62 * Load the storage/registration helper. 63 * 64 * @return helper_plugin_usersettings|null 65 */ 66 protected function getHelper() 67 { 68 /** @var helper_plugin_usersettings|null $helper */ 69 $helper = plugin_load('helper', 'usersettings'); 70 return $helper; 71 } 72 73 // --------------------------------------------------------------------- 74 // 1. The user-menu item 75 // --------------------------------------------------------------------- 76 77 /** 78 * Insert the "Preferences" item into the user menu, just before the 79 * "Update Profile" item. 80 * 81 * @param Doku_Event $event MENU_ITEMS_ASSEMBLY 82 * @param mixed $param 83 */ 84 public function handleMenuAssembly(Doku_Event $event, $param) 85 { 86 if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') { 87 return; 88 } 89 90 try { 91 $item = new \dokuwiki\plugin\usersettings\MenuItem(); 92 } catch (\RuntimeException $e) { 93 // anonymous visitor, or the action is disabled — no menu item 94 return; 95 } 96 97 if (!isset($event->data['items']) || !is_array($event->data['items'])) { 98 return; 99 } 100 $items =& $event->data['items']; 101 102 // find the Profile item; default to appending if it is not present 103 $pos = count($items); 104 foreach ($items as $i => $existing) { 105 if ($existing instanceof \dokuwiki\Menu\Item\Profile) { 106 $pos = $i; 107 break; 108 } 109 } 110 array_splice($items, $pos, 0, [$item]); 111 } 112 113 // --------------------------------------------------------------------- 114 // 2. Claiming the custom action + handling the save 115 // --------------------------------------------------------------------- 116 117 /** 118 * Claim do=usersettings and, on a form submission, save and redirect. 119 * 120 * @param Doku_Event $event ACTION_ACT_PREPROCESS 121 * @param mixed $param 122 */ 123 public function handlePreprocess(Doku_Event $event, $param) 124 { 125 if ($event->data !== self::ACTION) { 126 return; 127 } 128 129 // Preventing the default makes DokuWiki keep the action and route it 130 // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN. 131 $event->preventDefault(); 132 $event->stopPropagation(); 133 134 global $INPUT, $ID; 135 136 $user = $INPUT->server->str('REMOTE_USER'); 137 if ($user === '') { 138 return; // anonymous — the rendered page shows a login notice 139 } 140 141 // not a save submission — nothing to do, the page will just render 142 if (!$INPUT->post->bool('usersettings_save')) { 143 return; 144 } 145 146 // CSRF protection; checkSecurityToken() shows its own error on failure 147 if (!checkSecurityToken()) { 148 return; 149 } 150 151 $ok = $this->saveSubmittedPreferences($user); 152 msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1); 153 154 // Post/Redirect/Get: a refresh must not re-submit the form 155 send_redirect(wl($ID, ['do' => self::ACTION], true, '&')); 156 } 157 158 /** 159 * Read the submitted toggle values for every registered toggle and store 160 * them for the given user. 161 * 162 * Kept separate from handlePreprocess() so it carries no redirect and can 163 * be exercised directly by tests. Checkboxes that are unchecked do not 164 * appear in the POST data, so every registered toggle is read explicitly 165 * rather than iterating whatever was submitted. 166 * 167 * @param string $user whose preferences are being written 168 * @param string|null $actor who is making the change; defaults to $user 169 * (the admin component passes the admin here) 170 * @return bool 171 */ 172 public function saveSubmittedPreferences($user, $actor = null) 173 { 174 global $INPUT; 175 176 if ($actor === null) { 177 $actor = $user; 178 } 179 180 $helper = $this->getHelper(); 181 if ($helper === null) { 182 return false; 183 } 184 185 $values = []; 186 foreach ($helper->getRegisteredToggles() as $key => $def) { 187 if ($def['type'] === 'checkbox') { 188 $values[$key] = $INPUT->post->bool($key) ? 1 : 0; 189 } else { 190 $values[$key] = $INPUT->post->str($key); 191 } 192 } 193 194 return $helper->setPreferences($values, $user, $actor); 195 } 196 197 // --------------------------------------------------------------------- 198 // 3. Rendering the settings page 199 // --------------------------------------------------------------------- 200 201 /** 202 * Render the settings page for do=usersettings. 203 * 204 * @param Doku_Event $event TPL_ACT_UNKNOWN 205 * @param mixed $param 206 */ 207 public function handleUnknown(Doku_Event $event, $param) 208 { 209 if ($event->data !== self::ACTION) { 210 return; 211 } 212 $event->preventDefault(); 213 $event->stopPropagation(); 214 215 echo $this->renderSettingsPage(); 216 } 217 218 /** 219 * Build the HTML of the settings page. 220 * 221 * @return string 222 */ 223 public function renderSettingsPage() 224 { 225 global $INPUT, $ID; 226 227 $user = $INPUT->server->str('REMOTE_USER'); 228 229 $html = '<div class="plugin_usersettings">'; 230 $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 231 232 if ($user === '') { 233 $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>'; 234 return $html . '</div>'; 235 } 236 237 $helper = $this->getHelper(); 238 $toggles = $helper ? $helper->getRegisteredToggles() : []; 239 240 if (empty($toggles)) { 241 $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 242 return $html . '</div>'; 243 } 244 245 $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>'; 246 247 $action = wl($ID, ['do' => self::ACTION], false, '&'); 248 $html .= '<form method="post" action="' . $action . '" class="us-form">'; 249 $html .= formSecurityToken(false); 250 251 foreach ($toggles as $key => $def) { 252 $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user)); 253 } 254 255 $html .= '<div class="us-actions">'; 256 $html .= '<button type="submit" name="usersettings_save" value="1" class="button">' 257 . hsc($this->getLang('save')) . '</button>'; 258 $html .= '</div>'; 259 $html .= '</form>'; 260 261 return $html . '</div>'; 262 } 263 264 // --------------------------------------------------------------------- 265 // Built-in: interface language toggle 266 // --------------------------------------------------------------------- 267 268 /** 269 * Contribute the "Interface language" select to the usersettings registry. 270 * 271 * The option list is built by scanning DOKU_INC/inc/lang/ for sub- 272 * directories that contain a lang.php file — the same source the 273 * Configuration Manager uses for its own language drop-down. The scan 274 * result is sorted alphabetically by language code; the site default is 275 * used as the toggle's default value so the toggle appears pre-selected 276 * correctly for users who have never changed it. 277 * 278 * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER 279 * @param mixed $param 280 */ 281 public function registerLangToggle(Doku_Event $event, $param) 282 { 283 global $conf; 284 285 $options = $this->getAvailableLanguages(); 286 if (empty($options)) { 287 return; // nothing to register if we cannot list languages 288 } 289 290 $siteDefault = $conf['lang'] ?? 'en'; 291 if (!array_key_exists($siteDefault, $options)) { 292 $siteDefault = array_key_first($options); 293 } 294 295 $event->data[] = [ 296 'key' => 'lang', 297 'label' => $this->getLang('lang_label'), 298 'desc' => $this->getLang('lang_desc'), 299 'type' => 'select', 300 'options' => $options, 301 'default' => $siteDefault, 302 'plugin' => 'usersettings', 303 ]; 304 } 305 306 /** 307 * Build the [code => display name] map of all installed DokuWiki interface 308 * languages by scanning inc/lang/. The display name is the language code 309 * itself (e.g. "en", "de", "fr") — consistent with how the Configuration 310 * Manager presents the option. 311 * 312 * @return array [langCode => langCode] sorted by language code 313 */ 314 protected function getAvailableLanguages() 315 { 316 $pattern = DOKU_INC . 'inc/lang/*/lang.php'; 317 $files = glob($pattern); 318 if ($files === false || empty($files)) { 319 return []; 320 } 321 322 $langs = []; 323 foreach ($files as $file) { 324 $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php" 325 if ($code === '' || $code === '.' || $code === '..') { 326 continue; 327 } 328 $langs[$code] = $code; 329 } 330 331 ksort($langs, SORT_STRING); 332 return $langs; 333 } 334 335 /** 336 * Apply the logged-in user's preferred interface language, overriding the 337 * site-wide $conf['lang'] before any rendering takes place. 338 * 339 * DokuWiki loads language strings lazily (via getLang() / $lang global 340 * reloads triggered by calls to init_lang()), so changing $conf['lang'] 341 * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient 342 * to affect all subsequent output. 343 * 344 * No-op for anonymous visitors or when the user has not chosen a language 345 * that differs from the site default. 346 * 347 * @param Doku_Event $event ACTION_ACT_PREPROCESS 348 * @param mixed $param 349 */ 350 public function applyUserLang(Doku_Event $event, $param) 351 { 352 global $conf, $INPUT; 353 354 $user = $INPUT->server->str('REMOTE_USER'); 355 if ($user === '') { 356 return; // anonymous — use the site default 357 } 358 359 $helper = $this->getHelper(); 360 if ($helper === null) { 361 return; 362 } 363 364 $preferred = $helper->getPreference('lang', $user); 365 if ($preferred === null || $preferred === '' || $preferred === $conf['lang']) { 366 return; // no preference stored or already correct 367 } 368 369 // Validate: only apply if the directory actually exists to avoid a 370 // broken page when someone stores a stale language code. 371 $langDir = DOKU_INC . 'inc/lang/' . $preferred; 372 if (!is_dir($langDir)) { 373 return; 374 } 375 376 $conf['lang'] = $preferred; 377 378 // Re-initialise the global $lang array so immediately-following 379 // getLang() calls within this request pick up the new language. 380 init_lang($preferred); 381 382 // The PLUGIN_USERSETTINGS_REGISTER event fired during getPreference() 383 // above caused this action plugin's own locale to load under the old 384 // $conf['lang']. Reset the cache so subsequent getLang() calls on 385 // this instance (e.g. renderSettingsPage) load the user's language. 386 $this->localised = false; 387 $this->lang = []; 388 } 389 390 // --------------------------------------------------------------------- 391 // Form rendering (shared between action and admin) 392 // --------------------------------------------------------------------- 393 394 /** 395 * Render one toggle as a form row. Public so the admin component can 396 * reuse it for its per-user edit form. 397 * 398 * @param array $def a normalised toggle definition 399 * @param mixed $value the user's effective value for this toggle 400 * @return string 401 */ 402 public function renderToggleRow(array $def, $value) 403 { 404 $key = hsc($def['key']); 405 406 if ($def['type'] === 'select') { 407 $id = 'us__' . $key; 408 $html = '<div class="us-row us-row-select">'; 409 $html .= '<label class="us-label" for="' . $id . '">'; 410 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 411 $html .= '<select name="' . $key . '" id="' . $id . '">'; 412 foreach ($def['options'] as $optValue => $optLabel) { 413 $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : ''; 414 $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>' 415 . hsc((string) $optLabel) . '</option>'; 416 } 417 $html .= '</select>'; 418 $html .= '</label>'; 419 } else { 420 $checked = empty($value) ? '' : ' checked="checked"'; 421 $html = '<div class="us-row us-row-checkbox">'; 422 $html .= '<label class="us-label">'; 423 $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />'; 424 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 425 $html .= '</label>'; 426 } 427 428 if ($def['desc'] !== '') { 429 $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>'; 430 } 431 432 return $html . '</div>'; 433 } 434} 435