xref: /plugin/usersettings/action.php (revision 1ab406139ae52af26e6f9eaacaf1646e58521679)
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, '&amp;');
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