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