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