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