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 38 /** 39 * Load the storage/registration helper. 40 * 41 * @return helper_plugin_usersettings|null 42 */ 43 protected function getHelper() 44 { 45 /** @var helper_plugin_usersettings|null $helper */ 46 $helper = plugin_load('helper', 'usersettings'); 47 return $helper; 48 } 49 50 // --------------------------------------------------------------------- 51 // 1. The user-menu item 52 // --------------------------------------------------------------------- 53 54 /** 55 * Insert the "Preferences" item into the user menu, just before the 56 * "Update Profile" item. 57 * 58 * @param Doku_Event $event MENU_ITEMS_ASSEMBLY 59 * @param mixed $param 60 */ 61 public function handleMenuAssembly(Doku_Event $event, $param) 62 { 63 if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') { 64 return; 65 } 66 67 try { 68 $item = new \dokuwiki\plugin\usersettings\MenuItem(); 69 } catch (\RuntimeException $e) { 70 // anonymous visitor, or the action is disabled — no menu item 71 return; 72 } 73 74 if (!isset($event->data['items']) || !is_array($event->data['items'])) { 75 return; 76 } 77 $items =& $event->data['items']; 78 79 // find the Profile item; default to appending if it is not present 80 $pos = count($items); 81 foreach ($items as $i => $existing) { 82 if ($existing instanceof \dokuwiki\Menu\Item\Profile) { 83 $pos = $i; 84 break; 85 } 86 } 87 array_splice($items, $pos, 0, [$item]); 88 } 89 90 // --------------------------------------------------------------------- 91 // 2. Claiming the custom action + handling the save 92 // --------------------------------------------------------------------- 93 94 /** 95 * Claim do=usersettings and, on a form submission, save and redirect. 96 * 97 * @param Doku_Event $event ACTION_ACT_PREPROCESS 98 * @param mixed $param 99 */ 100 public function handlePreprocess(Doku_Event $event, $param) 101 { 102 if ($event->data !== self::ACTION) { 103 return; 104 } 105 106 // Preventing the default makes DokuWiki keep the action and route it 107 // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN. 108 $event->preventDefault(); 109 $event->stopPropagation(); 110 111 global $INPUT, $ID; 112 113 $user = $INPUT->server->str('REMOTE_USER'); 114 if ($user === '') { 115 return; // anonymous — the rendered page shows a login notice 116 } 117 118 // not a save submission — nothing to do, the page will just render 119 if (!$INPUT->post->bool('usersettings_save')) { 120 return; 121 } 122 123 // CSRF protection; checkSecurityToken() shows its own error on failure 124 if (!checkSecurityToken()) { 125 return; 126 } 127 128 $ok = $this->saveSubmittedPreferences($user); 129 msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1); 130 131 // Post/Redirect/Get: a refresh must not re-submit the form 132 send_redirect(wl($ID, ['do' => self::ACTION], true, '&')); 133 } 134 135 /** 136 * Read the submitted toggle values for every registered toggle and store 137 * them for the given user. 138 * 139 * Kept separate from handlePreprocess() so it carries no redirect and can 140 * be exercised directly by tests. Checkboxes that are unchecked do not 141 * appear in the POST data, so every registered toggle is read explicitly 142 * rather than iterating whatever was submitted. 143 * 144 * @param string $user whose preferences are being written 145 * @param string|null $actor who is making the change; defaults to $user 146 * (the admin component passes the admin here) 147 * @return bool 148 */ 149 public function saveSubmittedPreferences($user, $actor = null) 150 { 151 global $INPUT; 152 153 if ($actor === null) { 154 $actor = $user; 155 } 156 157 $helper = $this->getHelper(); 158 if ($helper === null) { 159 return false; 160 } 161 162 $values = []; 163 foreach ($helper->getRegisteredToggles() as $key => $def) { 164 if ($def['type'] === 'checkbox') { 165 $values[$key] = $INPUT->post->bool($key) ? 1 : 0; 166 } else { 167 $values[$key] = $INPUT->post->str($key); 168 } 169 } 170 171 return $helper->setPreferences($values, $user, $actor); 172 } 173 174 // --------------------------------------------------------------------- 175 // 3. Rendering the settings page 176 // --------------------------------------------------------------------- 177 178 /** 179 * Render the settings page for do=usersettings. 180 * 181 * @param Doku_Event $event TPL_ACT_UNKNOWN 182 * @param mixed $param 183 */ 184 public function handleUnknown(Doku_Event $event, $param) 185 { 186 if ($event->data !== self::ACTION) { 187 return; 188 } 189 $event->preventDefault(); 190 $event->stopPropagation(); 191 192 echo $this->renderSettingsPage(); 193 } 194 195 /** 196 * Build the HTML of the settings page. 197 * 198 * @return string 199 */ 200 public function renderSettingsPage() 201 { 202 global $INPUT, $ID; 203 204 $user = $INPUT->server->str('REMOTE_USER'); 205 206 $html = '<div class="plugin_usersettings">'; 207 $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 208 209 if ($user === '') { 210 $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>'; 211 return $html . '</div>'; 212 } 213 214 $helper = $this->getHelper(); 215 $toggles = $helper ? $helper->getRegisteredToggles() : []; 216 217 if (empty($toggles)) { 218 $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 219 return $html . '</div>'; 220 } 221 222 $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>'; 223 224 $action = wl($ID, ['do' => self::ACTION], false, '&'); 225 $html .= '<form method="post" action="' . $action . '" class="us-form">'; 226 $html .= formSecurityToken(false); 227 228 foreach ($toggles as $key => $def) { 229 $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user)); 230 } 231 232 $html .= '<div class="us-actions">'; 233 $html .= '<button type="submit" name="usersettings_save" value="1" class="button">' 234 . hsc($this->getLang('save')) . '</button>'; 235 $html .= '</div>'; 236 $html .= '</form>'; 237 238 return $html . '</div>'; 239 } 240 241 /** 242 * Render one toggle as a form row. Public so the admin component can 243 * reuse it for its per-user edit form. 244 * 245 * @param array $def a normalised toggle definition 246 * @param mixed $value the user's effective value for this toggle 247 * @return string 248 */ 249 public function renderToggleRow(array $def, $value) 250 { 251 $key = hsc($def['key']); 252 253 if ($def['type'] === 'select') { 254 $id = 'us__' . $key; 255 $html = '<div class="us-row us-row-select">'; 256 $html .= '<label class="us-label" for="' . $id . '">'; 257 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 258 $html .= '<select name="' . $key . '" id="' . $id . '">'; 259 foreach ($def['options'] as $optValue => $optLabel) { 260 $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : ''; 261 $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>' 262 . hsc((string) $optLabel) . '</option>'; 263 } 264 $html .= '</select>'; 265 $html .= '</label>'; 266 } else { 267 $checked = empty($value) ? '' : ' checked="checked"'; 268 $html = '<div class="us-row us-row-checkbox">'; 269 $html .= '<label class="us-label">'; 270 $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />'; 271 $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 272 $html .= '</label>'; 273 } 274 275 if ($def['desc'] !== '') { 276 $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>'; 277 } 278 279 return $html . '</div>'; 280 } 281} 282