1<?php
2
3use dokuwiki\Extension\AdminPlugin;
4use dokuwiki\Extension\AuthPlugin;
5use dokuwiki\Utf8\Clean;
6use dokuwiki\Utf8\Conversion;
7
8/*
9 *  User Manager
10 *
11 *  Dokuwiki Admin Plugin
12 *
13 *  This version of the user manager has been modified to only work with
14 *  objectified version of auth system
15 *
16 *  @author  neolao <neolao@neolao.com>
17 *  @author  Chris Smith <chris@jalakai.co.uk>
18 */
19
20/**
21 * All DokuWiki plugins to extend the admin function
22 * need to inherit from this class
23 */
24class admin_plugin_usermanager extends AdminPlugin
25{
26    protected const IMAGE_DIR = DOKU_BASE . 'lib/plugins/usermanager/images/';
27
28    protected $auth;        // auth object
29    protected $users_total = 0;     // number of registered users
30    protected $filter = [];   // user selection filter(s)
31    protected $start = 0;          // index of first user to be displayed
32    protected $last = 0;           // index of the last user to be displayed
33    protected $pagesize = 20;      // number of users to list on one page
34    protected $edit_user = '';     // set to user selected for editing
35    protected $edit_userdata = [];
36    protected $disabled = '';      // if disabled set to explanatory string
37    protected $import_failures = [];
38    protected $lastdisabled = false; // set to true if last user is unknown and last button is hence buggy
39
40    /**
41     * Constructor
42     */
43    public function __construct()
44    {
45        /** @var AuthPlugin $auth */
46        global $auth;
47
48        $this->setupLocale();
49
50        if (!$auth instanceof AuthPlugin) {
51            $this->disabled = $this->lang['noauth'];
52        } elseif (!$auth->canDo('getUsers')) {
53            $this->disabled = $this->lang['nosupport'];
54        } else {
55            // we're good to go
56            $this->auth = &$auth;
57        }
58
59        // attempt to retrieve any import failures from the session
60        if (!empty($_SESSION['import_failures'])) {
61            $this->import_failures = $_SESSION['import_failures'];
62        }
63    }
64
65    /**
66     * Return prompt for admin menu
67     *
68     * @param string $language
69     * @return string
70     */
71    public function getMenuText($language)
72    {
73
74        if (!is_null($this->auth))
75            return parent::getMenuText($language);
76
77        return $this->getLang('menu') . ' ' . $this->disabled;
78    }
79
80    /**
81     * return sort order for position in admin menu
82     *
83     * @return int
84     */
85    public function getMenuSort()
86    {
87        return 2;
88    }
89
90    /**
91     * @return int current start value for pageination
92     */
93    public function getStart()
94    {
95        return $this->start;
96    }
97
98    /**
99     * @return int number of users per page
100     */
101    public function getPagesize()
102    {
103        return $this->pagesize;
104    }
105
106    /**
107     * @param boolean $lastdisabled
108     */
109    public function setLastdisabled($lastdisabled)
110    {
111        $this->lastdisabled = $lastdisabled;
112    }
113
114    /**
115     * Handle user request
116     *
117     * @return bool
118     */
119    public function handle()
120    {
121        global $INPUT;
122        if (is_null($this->auth)) return false;
123
124        // extract the command and any specific parameters
125        // submit button name is of the form - fn[cmd][param(s)]
126        $fn = $INPUT->param('fn');
127
128        if (is_array($fn)) {
129            $cmd = key($fn);
130            $param = is_array($fn[$cmd]) ? key($fn[$cmd]) : null;
131        } else {
132            $cmd = $fn;
133            $param = null;
134        }
135
136        if ($cmd != "search") {
137            $this->start = $INPUT->int('start', 0);
138            $this->filter = $this->retrieveFilter();
139        }
140
141        switch ($cmd) {
142            case "add":
143                $this->addUser();
144                break;
145            case "delete":
146                $this->deleteUser();
147                break;
148            case "modify":
149                $this->modifyUser();
150                break;
151            case "edit":
152                $this->editUser($param);
153                break;
154            case "search":
155                $this->setFilter($param);
156                $this->start = 0;
157                break;
158            case "export":
159                $this->exportCSV();
160                break;
161            case "import":
162                $this->importCSV();
163                break;
164            case "importfails":
165                $this->downloadImportFailures();
166                break;
167        }
168
169        $this->users_total = $this->auth->canDo('getUserCount') ? $this->auth->getUserCount($this->filter) : -1;
170
171        // page handling
172        switch ($cmd) {
173            case 'start':
174                $this->start = 0;
175                break;
176            case 'prev':
177                $this->start -= $this->pagesize;
178                break;
179            case 'next':
180                $this->start += $this->pagesize;
181                break;
182            case 'last':
183                $this->start = $this->users_total;
184                break;
185        }
186        $this->validatePagination();
187        return true;
188    }
189
190    /**
191     * Output appropriate html
192     *
193     * @return bool
194     * @todo split into smaller functions, use Form class
195     */
196    public function html()
197    {
198        global $ID;
199
200        if (is_null($this->auth)) {
201            echo $this->lang['badauth'];
202            return false;
203        }
204
205        $user_list = $this->auth->retrieveUsers($this->start, $this->pagesize, $this->filter);
206
207        $page_buttons = $this->pagination();
208        $delete_disable = $this->auth->canDo('delUser') ? '' : 'disabled="disabled"';
209
210        $editable = $this->auth->canDo('UserMod');
211        $export_label = empty($this->filter) ? $this->lang['export_all'] : $this->lang['export_filtered'];
212
213        echo $this->locale_xhtml('intro');
214        echo $this->locale_xhtml('list');
215
216        echo '<div id="user__manager">';
217        echo '<div class="level2">';
218
219        if ($this->users_total > 0) {
220            printf(
221                '<p>' . $this->lang['summary'] . '</p>',
222                $this->start + 1,
223                $this->last,
224                $this->users_total,
225                $this->auth->getUserCount()
226            );
227        } else {
228            if ($this->users_total < 0) {
229                $allUserTotal = 0;
230            } else {
231                $allUserTotal = $this->auth->getUserCount();
232            }
233            printf('<p>%s</p>', sprintf($this->lang['nonefound'], $allUserTotal));
234        }
235        printf('<form action="%s" method="post">', wl($ID));
236        formSecurityToken();
237        echo '<div class="table">';
238        echo '<table class="inline">';
239        echo '<thead>';
240        echo '<tr>';
241        echo '<th>&#160;</th>';
242        echo '<th>' . $this->lang["user_id"] . '</th>';
243        echo '<th>' . $this->lang["user_name"] . '</th>';
244        echo '<th>' . $this->lang["user_mail"] . '</th>';
245        echo '<th>' . $this->lang["user_groups"] . '</th>';
246        echo '</tr>';
247
248        echo '<tr>';
249        echo '<td class="rightalign"><input type="image" src="' .
250            self::IMAGE_DIR . 'search.png" name="fn[search][new]" title="' .
251            $this->lang['search_prompt'] . '" alt="' . $this->lang['search'] . '" class="button" /></td>';
252        echo '<td><input type="text" name="userid" class="edit" value="' . $this->htmlFilter('user') . '" /></td>';
253        echo '<td><input type="text" name="username" class="edit" value="' . $this->htmlFilter('name') . '" /></td>';
254        echo '<td><input type="text" name="usermail" class="edit" value="' . $this->htmlFilter('mail') . '" /></td>';
255        echo '<td><input type="text" name="usergroups" class="edit" value="' . $this->htmlFilter('grps') . '" /></td>';
256        echo '</tr>';
257        echo '</thead>';
258
259        if ($this->users_total) {
260            echo '<tbody>';
261            foreach ($user_list as $user => $userinfo) {
262                extract($userinfo);
263                /**
264                 * @var string $name
265                 * @var string $pass
266                 * @var string $mail
267                 * @var array $grps
268                 */
269                $groups = implode(', ', $grps);
270                echo '<tr class="user_info">';
271                echo '<td class="centeralign"><input type="checkbox" name="delete[' . hsc($user) .
272                    ']" ' . $delete_disable . ' /></td>';
273                if ($editable) {
274                    echo '<td><a href="' . wl($ID, ['fn[edit][' . $user . ']' => 1,
275                            'do' => 'admin',
276                            'page' => 'usermanager',
277                            'sectok' => getSecurityToken()]) .
278                        '" title="' . $this->lang['edit_prompt'] . '">' . hsc($user) . '</a></td>';
279                } else {
280                    echo '<td>' . hsc($user) . '</td>';
281                }
282                echo '<td>' . hsc($name) . '</td><td>' . hsc($mail) . '</td><td>' . hsc($groups) . '</td>';
283                echo '</tr>';
284            }
285            echo '</tbody>';
286        }
287
288        echo '<tbody>';
289        echo '<tr><td colspan="5" class="centeralign">';
290        echo '<span class="medialeft">';
291        echo '<button type="submit" name="fn[delete]" id="usrmgr__del" ' . $delete_disable . '>' .
292            $this->lang['delete_selected'] . '</button>';
293        echo '</span>';
294        echo '<span class="mediaright">';
295        echo '<button type="submit" name="fn[start]" ' . $page_buttons['start'] . '>' .
296            $this->lang['start'] . '</button>';
297        echo '<button type="submit" name="fn[prev]" ' . $page_buttons['prev'] . '>' .
298            $this->lang['prev'] . "</button>";
299        echo '<button type="submit" name="fn[next]" ' . $page_buttons['next'] . '>' .
300            $this->lang['next'] . '</button>';
301        echo '<button type="submit" name="fn[last]" ' . $page_buttons['last'] . '>' .
302            $this->lang['last'] . '</button>';
303        echo '</span>';
304        if (!empty($this->filter)) {
305            echo '<button type="submit" name="fn[search][clear]">' . $this->lang['clear'] . '</button>';
306        }
307        echo '<button type="submit" name="fn[export]">' . $export_label . '</button>';
308        echo '<input type="hidden" name="do"    value="admin" />';
309        echo '<input type="hidden" name="page"  value="usermanager" />';
310
311        $this->htmlFilterSettings(2);
312
313        echo '</td></tr>';
314        echo '</tbody>';
315        echo '</table>';
316        echo '</div>';
317
318        echo '</form>';
319        echo '</div>';
320
321        $style = $this->edit_user ? ' class="edit_user"' : '';
322
323        if ($this->auth->canDo('addUser')) {
324            echo '<div' . $style . '>';
325            echo $this->locale_xhtml('add');
326            echo '<div class="level2">';
327
328            $this->htmlUserForm('add', null, [], 4);
329
330            echo '</div>';
331            echo '</div>';
332        }
333
334        if ($this->edit_user && $this->auth->canDo('UserMod')) {
335            echo '<div' . $style . ' id="scroll__here">';
336            echo $this->locale_xhtml('edit');
337            echo '<div class="level2">';
338
339            $this->htmlUserForm('modify', $this->edit_user, $this->edit_userdata, 4);
340
341            echo '</div>';
342            echo '</div>';
343        }
344
345        if ($this->auth->canDo('addUser')) {
346            $this->htmlImportForm();
347        }
348        echo '</div>';
349        return true;
350    }
351
352    /**
353     * User Manager is only available if the auth backend supports it
354     *
355     * @inheritdoc
356     * @return bool
357     */
358    public function isAccessibleByCurrentUser()
359    {
360        /** @var AuthPlugin $auth */
361        global $auth;
362        if (!$auth instanceof AuthPlugin || !$auth->canDo('getUsers')) {
363            return false;
364        }
365
366        return parent::isAccessibleByCurrentUser();
367    }
368
369
370    /**
371     * Display form to add or modify a user
372     *
373     * @param string $cmd 'add' or 'modify'
374     * @param string $user id of user
375     * @param array $userdata array with name, mail, pass and grps
376     * @param int $indent
377     * @todo use Form class
378     */
379    protected function htmlUserForm($cmd, $user = '', $userdata = [], $indent = 0)
380    {
381        global $conf;
382        global $ID;
383        global $lang;
384        $name = '';
385        $mail = '';
386        $groups = '';
387        $notes = [];
388
389        if ($user) {
390            extract($userdata);
391            if (!empty($grps)) $groups = implode(',', $grps);
392        } else {
393            $notes[] = sprintf($this->lang['note_group'], $conf['defaultgroup']);
394        }
395
396        printf('<form action="%s" method="post">', wl($ID));
397        formSecurityToken();
398        echo '<div class="table">';
399        echo '<table class="inline">';
400        echo '<thead>';
401        echo '<tr><th>' . $this->lang["field"] . "</th><th>" . $this->lang["value"] . "</th></tr>";
402        echo '</thead>';
403        echo '<tbody>';
404
405        $this->htmlInputField(
406            $cmd . "_userid",
407            "userid",
408            $this->lang["user_id"],
409            $user,
410            $this->auth->canDo("modLogin"),
411            true,
412            $indent + 6
413        );
414        $this->htmlInputField(
415            $cmd . "_userpass",
416            "userpass",
417            $this->lang["user_pass"],
418            "",
419            $this->auth->canDo("modPass"),
420            false,
421            $indent + 6
422        );
423        $this->htmlInputField(
424            $cmd . "_userpass2",
425            "userpass2",
426            $lang["passchk"],
427            "",
428            $this->auth->canDo("modPass"),
429            false,
430            $indent + 6
431        );
432        $this->htmlInputField(
433            $cmd . "_username",
434            "username",
435            $this->lang["user_name"],
436            $name,
437            $this->auth->canDo("modName"),
438            true,
439            $indent + 6
440        );
441        $this->htmlInputField(
442            $cmd . "_usermail",
443            "usermail",
444            $this->lang["user_mail"],
445            $mail,
446            $this->auth->canDo("modMail"),
447            true,
448            $indent + 6
449        );
450        $this->htmlInputField(
451            $cmd . "_usergroups",
452            "usergroups",
453            $this->lang["user_groups"],
454            $groups,
455            $this->auth->canDo("modGroups"),
456            false,
457            $indent + 6
458        );
459
460        if ($this->auth->canDo("modPass")) {
461            if ($cmd == 'add') {
462                $notes[] = $this->lang['note_pass'];
463            }
464            if ($user) {
465                $notes[] = $this->lang['note_notify'];
466            }
467
468            echo '<tr><td><label for="' . $cmd . "_usernotify\" >" .
469                $this->lang["user_notify"] . ': </label></td>
470                 <td><input type="checkbox" id="' . $cmd . '_usernotify" name="usernotify" value="1" />
471                 </td></tr>';
472        }
473
474        echo '</tbody>';
475        echo '<tbody>';
476        echo '<tr>';
477        echo '<td colspan="2">';
478        echo '<input type="hidden" name="do"    value="admin" />';
479        echo '<input type="hidden" name="page"  value="usermanager" />';
480
481        // save current $user, we need this to access details if the name is changed
482        if ($user) {
483            echo '<input type="hidden" name="userid_old"  value="' . hsc($user) . "\" />";
484        }
485
486        $this->htmlFilterSettings($indent + 10);
487
488        echo '<button type="submit" name="fn[' . $cmd . ']">' . $this->lang[$cmd] . '</button>';
489        echo '</td>';
490        echo '</tr>';
491        echo '</tbody>';
492        echo '</table>';
493
494        if ($notes) {
495            echo '<ul class="notes">';
496            foreach ($notes as $note) {
497                echo '<li><span class="li">' . $note . '</li>';
498            }
499            echo '</ul>';
500        }
501        echo '</div>';
502        echo '</form>';
503    }
504
505    /**
506     * Prints a inputfield
507     *
508     * @param string $id
509     * @param string $name
510     * @param string $label
511     * @param string $value
512     * @param bool $cando whether auth backend is capable to do this action
513     * @param bool $required is this field required?
514     * @param int $indent
515     * @todo obsolete when Form class is used
516     */
517    protected function htmlInputField($id, $name, $label, $value, $cando, $required, $indent = 0)
518    {
519        $class = $cando ? '' : ' class="disabled"';
520        echo str_pad('', $indent);
521
522        if ($name == 'userpass' || $name == 'userpass2') {
523            $fieldtype = 'password';
524            $autocomp = 'autocomplete="off"';
525        } elseif ($name == 'usermail') {
526            $fieldtype = 'email';
527            $autocomp = '';
528        } else {
529            $fieldtype = 'text';
530            $autocomp = '';
531        }
532        $value = hsc($value);
533
534        echo "<tr $class>";
535        echo "<td><label for=\"$id\" >$label: </label></td>";
536        echo '<td>';
537        if ($cando) {
538            $req = '';
539            if ($required) $req = 'required="required"';
540            echo "<input type=\"$fieldtype\" id=\"$id\" name=\"$name\"
541                  value=\"$value\" class=\"edit\" $autocomp $req />";
542        } else {
543            echo "<input type=\"hidden\" name=\"$name\" value=\"$value\" />";
544            echo "<input type=\"$fieldtype\" id=\"$id\" name=\"$name\"
545                  value=\"$value\" class=\"edit disabled\" disabled=\"disabled\" />";
546        }
547        echo '</td>';
548        echo '</tr>';
549    }
550
551    /**
552     * Returns htmlescaped filter value
553     *
554     * @param string $key name of search field
555     * @return string html escaped value
556     */
557    protected function htmlFilter($key)
558    {
559        if (empty($this->filter)) return '';
560        return (isset($this->filter[$key]) ? hsc($this->filter[$key]) : '');
561    }
562
563    /**
564     * Print hidden inputs with the current filter values
565     *
566     * @param int $indent
567     */
568    protected function htmlFilterSettings($indent = 0)
569    {
570
571        echo '<input type="hidden" name="start" value="' . $this->start . '" />';
572
573        foreach ($this->filter as $key => $filter) {
574            echo '<input type="hidden" name="filter[' . $key . ']" value="' . hsc($filter) . '" />';
575        }
576    }
577
578    /**
579     * Print import form and summary of previous import
580     *
581     * @param int $indent
582     */
583    protected function htmlImportForm($indent = 0)
584    {
585        global $ID;
586
587        $failure_download_link = wl($ID, ['do' => 'admin', 'page' => 'usermanager', 'fn[importfails]' => 1]);
588
589        echo '<div class="level2 import_users">';
590        echo $this->locale_xhtml('import');
591        echo '<form action="' . wl($ID) . '" method="post" enctype="multipart/form-data">';
592        formSecurityToken();
593        echo '<label>' . $this->lang['import_userlistcsv'] . '<input type="file" name="import" /></label>';
594        echo '<button type="submit" name="fn[import]">' . $this->lang['import'] . '</button>';
595        echo '<input type="hidden" name="do"    value="admin" />';
596        echo '<input type="hidden" name="page"  value="usermanager" />';
597
598        $this->htmlFilterSettings($indent + 4);
599        echo '</form>';
600        echo '</div>';
601
602        // list failures from the previous import
603        if ($this->import_failures) {
604            $digits = strlen(count($this->import_failures));
605            echo '<div class="level3 import_failures">';
606            echo '<h3>' . $this->lang['import_header'] . '</h3>';
607            echo '<table class="import_failures">';
608            echo '<thead>';
609            echo '<tr>';
610            echo '<th class="line">' . $this->lang['line'] . '</th>';
611            echo '<th class="error">' . $this->lang['error'] . '</th>';
612            echo '<th class="userid">' . $this->lang['user_id'] . '</th>';
613            echo '<th class="username">' . $this->lang['user_name'] . '</th>';
614            echo '<th class="usermail">' . $this->lang['user_mail'] . '</th>';
615            echo '<th class="usergroups">' . $this->lang['user_groups'] . '</th>';
616            echo '</tr>';
617            echo '</thead>';
618            echo '<tbody>';
619            foreach ($this->import_failures as $line => $failure) {
620                echo '<tr>';
621                echo '<td class="lineno"> ' . sprintf('%0' . $digits . 'd', $line) . ' </td>';
622                echo '<td class="error">' . $failure['error'] . ' </td>';
623                echo '<td class="field userid"> ' . hsc($failure['user'][0]) . ' </td>';
624                echo '<td class="field username"> ' . hsc($failure['user'][2]) . ' </td>';
625                echo '<td class="field usermail"> ' . hsc($failure['user'][3]) . ' </td>';
626                echo '<td class="field usergroups"> ' . hsc($failure['user'][4]) . ' </td>';
627                echo '</tr>';
628            }
629            echo '</tbody>';
630            echo '</table>';
631            echo '<p><a href="' . $failure_download_link . '">' . $this->lang['import_downloadfailures'] . '</a></p>';
632            echo '</div>';
633        }
634    }
635
636    /**
637     * Add an user to auth backend
638     *
639     * @return bool whether succesful
640     */
641    protected function addUser()
642    {
643        global $INPUT;
644        if (!checkSecurityToken()) return false;
645        if (!$this->auth->canDo('addUser')) return false;
646
647        [$user, $pass, $name, $mail, $grps, $passconfirm] = $this->retrieveUser();
648        if (empty($user)) return false;
649
650        if ($this->auth->canDo('modPass')) {
651            if (empty($pass)) {
652                if ($INPUT->has('usernotify')) {
653                    $pass = auth_pwgen($user);
654                } else {
655                    msg($this->lang['add_fail'], -1);
656                    msg($this->lang['addUser_error_missing_pass'], -1);
657                    return false;
658                }
659            } elseif (!$this->verifyPassword($pass, $passconfirm)) {
660                msg($this->lang['add_fail'], -1);
661                msg($this->lang['addUser_error_pass_not_identical'], -1);
662                return false;
663            }
664        } elseif (!empty($pass)) {
665            msg($this->lang['add_fail'], -1);
666            msg($this->lang['addUser_error_modPass_disabled'], -1);
667            return false;
668        }
669
670        if ($this->auth->canDo('modName')) {
671            if (empty($name)) {
672                msg($this->lang['add_fail'], -1);
673                msg($this->lang['addUser_error_name_missing'], -1);
674                return false;
675            }
676        } elseif (!empty($name)) {
677            msg($this->lang['add_fail'], -1);
678            msg($this->lang['addUser_error_modName_disabled'], -1);
679            return false;
680        }
681
682        if ($this->auth->canDo('modMail')) {
683            if (empty($mail)) {
684                msg($this->lang['add_fail'], -1);
685                msg($this->lang['addUser_error_mail_missing'], -1);
686                return false;
687            }
688        } elseif (!empty($mail)) {
689            msg($this->lang['add_fail'], -1);
690            msg($this->lang['addUser_error_modMail_disabled'], -1);
691            return false;
692        }
693
694        if ($ok = $this->auth->triggerUserMod('create', [$user, $pass, $name, $mail, $grps])) {
695            msg($this->lang['add_ok'], 1);
696
697            if ($INPUT->has('usernotify') && $pass) {
698                $this->notifyUser($user, $pass);
699            }
700        } else {
701            msg($this->lang['add_fail'], -1);
702            msg($this->lang['addUser_error_create_event_failed'], -1);
703        }
704
705        return $ok;
706    }
707
708    /**
709     * Delete user from auth backend
710     *
711     * @return bool whether succesful
712     */
713    protected function deleteUser()
714    {
715        global $conf, $INPUT;
716
717        if (!checkSecurityToken()) return false;
718        if (!$this->auth->canDo('delUser')) return false;
719
720        $selected = $INPUT->arr('delete');
721        if (empty($selected)) return false;
722        $selected = array_keys($selected);
723
724        if (in_array($_SERVER['REMOTE_USER'], $selected)) {
725            msg("You can't delete yourself!", -1);
726            return false;
727        }
728
729        $count = $this->auth->triggerUserMod('delete', [$selected]);
730        if ($count == count($selected)) {
731            $text = str_replace('%d', $count, $this->lang['delete_ok']);
732            msg("$text.", 1);
733        } else {
734            $part1 = str_replace('%d', $count, $this->lang['delete_ok']);
735            $part2 = str_replace('%d', (count($selected) - $count), $this->lang['delete_fail']);
736            msg("$part1, $part2", -1);
737        }
738
739        // invalidate all sessions
740        io_saveFile($conf['cachedir'] . '/sessionpurge', time());
741
742        return true;
743    }
744
745    /**
746     * Edit user (a user has been selected for editing)
747     *
748     * @param string $param id of the user
749     * @return bool whether succesful
750     */
751    protected function editUser($param)
752    {
753        if (!checkSecurityToken()) return false;
754        if (!$this->auth->canDo('UserMod')) return false;
755        $user = $this->auth->cleanUser(preg_replace('/.*[:\/]/', '', $param));
756        $userdata = $this->auth->getUserData($user);
757
758        // no user found?
759        if (!$userdata) {
760            msg($this->lang['edit_usermissing'], -1);
761            return false;
762        }
763
764        $this->edit_user = $user;
765        $this->edit_userdata = $userdata;
766
767        return true;
768    }
769
770    /**
771     * Modify user in the auth backend (modified user data has been recieved)
772     *
773     * @return bool whether succesful
774     */
775    protected function modifyUser()
776    {
777        global $conf, $INPUT;
778
779        if (!checkSecurityToken()) return false;
780        if (!$this->auth->canDo('UserMod')) return false;
781
782        // get currently valid  user data
783        $olduser = $this->auth->cleanUser(preg_replace('/.*[:\/]/', '', $INPUT->str('userid_old')));
784        $oldinfo = $this->auth->getUserData($olduser);
785
786        // get new user data subject to change
787        [$newuser, $newpass, $newname, $newmail, $newgrps, $passconfirm] = $this->retrieveUser();
788        if (empty($newuser)) return false;
789
790        $changes = [];
791        if ($newuser != $olduser) {
792            if (!$this->auth->canDo('modLogin')) {        // sanity check, shouldn't be possible
793                msg($this->lang['update_fail'], -1);
794                return false;
795            }
796
797            // check if $newuser already exists
798            if ($this->auth->getUserData($newuser)) {
799                msg(sprintf($this->lang['update_exists'], $newuser), -1);
800                $re_edit = true;
801            } else {
802                $changes['user'] = $newuser;
803            }
804        }
805        if ($this->auth->canDo('modPass')) {
806            if ($newpass || $passconfirm) {
807                if ($this->verifyPassword($newpass, $passconfirm)) {
808                    $changes['pass'] = $newpass;
809                } else {
810                    return false;
811                }
812            } elseif ($INPUT->has('usernotify')) {
813                // no new password supplied, check if we need to generate one (or it stays unchanged)
814                $changes['pass'] = auth_pwgen($olduser);
815            }
816        }
817
818        if (!empty($newname) && $this->auth->canDo('modName') && $newname != $oldinfo['name']) {
819            $changes['name'] = $newname;
820        }
821        if (!empty($newmail) && $this->auth->canDo('modMail') && $newmail != $oldinfo['mail']) {
822            $changes['mail'] = $newmail;
823        }
824        if (!empty($newgrps) && $this->auth->canDo('modGroups') && $newgrps != $oldinfo['grps']) {
825            $changes['grps'] = $newgrps;
826        }
827
828        if ($ok = $this->auth->triggerUserMod('modify', [$olduser, $changes])) {
829            msg($this->lang['update_ok'], 1);
830
831            if ($INPUT->has('usernotify') && !empty($changes['pass'])) {
832                $notify = empty($changes['user']) ? $olduser : $newuser;
833                $this->notifyUser($notify, $changes['pass']);
834            }
835
836            // invalidate all sessions
837            io_saveFile($conf['cachedir'] . '/sessionpurge', time());
838        } else {
839            msg($this->lang['update_fail'], -1);
840        }
841
842        if (!empty($re_edit)) {
843            $this->editUser($olduser);
844        }
845
846        return $ok;
847    }
848
849    /**
850     * Send password change notification email
851     *
852     * @param string $user id of user
853     * @param string $password plain text
854     * @param bool $status_alert whether status alert should be shown
855     * @return bool whether succesful
856     */
857    protected function notifyUser($user, $password, $status_alert = true)
858    {
859
860        if ($sent = auth_sendPassword($user, $password)) {
861            if ($status_alert) {
862                msg($this->lang['notify_ok'], 1);
863            }
864        } elseif ($status_alert) {
865            msg($this->lang['notify_fail'], -1);
866        }
867
868        return $sent;
869    }
870
871    /**
872     * Verify password meets minimum requirements
873     * :TODO: extend to support password strength
874     *
875     * @param string $password candidate string for new password
876     * @param string $confirm repeated password for confirmation
877     * @return bool   true if meets requirements, false otherwise
878     */
879    protected function verifyPassword($password, $confirm)
880    {
881        global $lang;
882
883        if (empty($password) && empty($confirm)) {
884            return false;
885        }
886
887        if ($password !== $confirm) {
888            msg($lang['regbadpass'], -1);
889            return false;
890        }
891
892        // :TODO: test password for required strength
893
894        // if we make it this far the password is good
895        return true;
896    }
897
898    /**
899     * Retrieve & clean user data from the form
900     *
901     * @param bool $clean whether the cleanUser method of the authentication backend is applied
902     * @return array (user, password, full name, email, array(groups))
903     */
904    protected function retrieveUser($clean = true)
905    {
906        /** @var AuthPlugin $auth */
907        global $auth;
908        global $INPUT;
909
910        $user = [];
911        $user[0] = ($clean) ? $auth->cleanUser($INPUT->str('userid')) : $INPUT->str('userid');
912        $user[1] = $INPUT->str('userpass');
913        $user[2] = $INPUT->str('username');
914        $user[3] = $INPUT->str('usermail');
915        $user[4] = explode(',', $INPUT->str('usergroups'));
916        $user[5] = $INPUT->str('userpass2'); // repeated password for confirmation
917
918        $user[4] = array_map('trim', $user[4]);
919        if ($clean) {
920            $user[4] = array_map([$auth, 'cleanGroup'], $user[4]);
921        }
922        $user[4] = array_filter($user[4]);
923        $user[4] = array_unique($user[4]);
924        if ($user[4] === []) {
925            $user[4] = null;
926        }
927
928        return $user;
929    }
930
931    /**
932     * Set the filter with the current search terms or clear the filter
933     *
934     * @param string $op 'new' or 'clear'
935     */
936    protected function setFilter($op)
937    {
938
939        $this->filter = [];
940
941        if ($op == 'new') {
942            [$user, /* pass */, $name, $mail, $grps] = $this->retrieveUser(false);
943
944            if (!empty($user)) $this->filter['user'] = $user;
945            if (!empty($name)) $this->filter['name'] = $name;
946            if (!empty($mail)) $this->filter['mail'] = $mail;
947            if (!empty($grps)) $this->filter['grps'] = implode('|', $grps);
948        }
949    }
950
951    /**
952     * Get the current search terms
953     *
954     * @return array
955     */
956    protected function retrieveFilter()
957    {
958        global $INPUT;
959
960        $t_filter = $INPUT->arr('filter');
961
962        // messy, but this way we ensure we aren't getting any additional crap from malicious users
963        $filter = [];
964
965        if (isset($t_filter['user'])) $filter['user'] = $t_filter['user'];
966        if (isset($t_filter['name'])) $filter['name'] = $t_filter['name'];
967        if (isset($t_filter['mail'])) $filter['mail'] = $t_filter['mail'];
968        if (isset($t_filter['grps'])) $filter['grps'] = $t_filter['grps'];
969
970        return $filter;
971    }
972
973    /**
974     * Validate and improve the pagination values
975     */
976    protected function validatePagination()
977    {
978
979        if ($this->start >= $this->users_total) {
980            $this->start = $this->users_total - $this->pagesize;
981        }
982        if ($this->start < 0) $this->start = 0;
983
984        $this->last = min($this->users_total, $this->start + $this->pagesize);
985    }
986
987    /**
988     * Return an array of strings to enable/disable pagination buttons
989     *
990     * @return array with enable/disable attributes
991     */
992    protected function pagination()
993    {
994
995        $disabled = 'disabled="disabled"';
996
997        $buttons = [];
998        $buttons['start'] = $buttons['prev'] = ($this->start == 0) ? $disabled : '';
999
1000        if ($this->users_total == -1) {
1001            $buttons['last'] = $disabled;
1002            $buttons['next'] = '';
1003        } else {
1004            $buttons['last'] = $buttons['next'] =
1005                (($this->start + $this->pagesize) >= $this->users_total) ? $disabled : '';
1006        }
1007
1008        if ($this->lastdisabled) {
1009            $buttons['last'] = $disabled;
1010        }
1011
1012        return $buttons;
1013    }
1014
1015    /**
1016     * Export a list of users in csv format using the current filter criteria
1017     */
1018    protected function exportCSV()
1019    {
1020        // list of users for export - based on current filter criteria
1021        $user_list = $this->auth->retrieveUsers(0, 0, $this->filter);
1022        $column_headings = [
1023            $this->lang["user_id"],
1024            $this->lang["user_name"],
1025            $this->lang["user_mail"],
1026            $this->lang["user_groups"]
1027        ];
1028
1029        // ==============================================================================================
1030        // GENERATE OUTPUT
1031        // normal headers for downloading...
1032        header('Content-type: text/csv;charset=utf-8');
1033        header('Content-Disposition: attachment; filename="wikiusers.csv"');
1034#       // for debugging assistance, send as text plain to the browser
1035#       header('Content-type: text/plain;charset=utf-8');
1036
1037        // output the csv
1038        $fd = fopen('php://output', 'w');
1039        fputcsv($fd, $column_headings, ',', '"', "\\");
1040        foreach ($user_list as $user => $info) {
1041            $line = [$user, $info['name'], $info['mail'], implode(',', $info['grps'])];
1042            fputcsv($fd, $line, ',', '"', "\\");
1043        }
1044        fclose($fd);
1045        if (defined('DOKU_UNITTEST')) {
1046            return;
1047        }
1048
1049        die;
1050    }
1051
1052    /**
1053     * Import a file of users in csv format
1054     *
1055     * csv file should have 4 columns, user_id, full name, email, groups (comma separated)
1056     *
1057     * @return bool whether successful
1058     */
1059    protected function importCSV()
1060    {
1061        // check we are allowed to add users
1062        if (!checkSecurityToken()) return false;
1063        if (!$this->auth->canDo('addUser')) return false;
1064
1065        // check file uploaded ok.
1066        if (
1067            empty($_FILES['import']['size']) ||
1068            !empty($_FILES['import']['error']) && $this->isUploadedFile($_FILES['import']['tmp_name'])
1069        ) {
1070            msg($this->lang['import_error_upload'], -1);
1071            return false;
1072        }
1073        // retrieve users from the file
1074        $this->import_failures = [];
1075        $import_success_count = 0;
1076        $import_fail_count = 0;
1077        $line = 0;
1078        $fd = fopen($_FILES['import']['tmp_name'], 'r');
1079        if ($fd) {
1080            while ($csv = fgets($fd)) {
1081                if (!Clean::isUtf8($csv)) {
1082                    $csv = Conversion::fromLatin1($csv);
1083                }
1084                $raw = str_getcsv($csv, ',', '"', "\\");
1085                $error = '';                        // clean out any errors from the previous line
1086                // data checks...
1087                if (1 == ++$line) {
1088                    if ($raw[0] == 'user_id' || $raw[0] == $this->lang['user_id']) continue;    // skip headers
1089                }
1090                if (count($raw) < 4) {                                        // need at least four fields
1091                    $import_fail_count++;
1092                    $error = sprintf($this->lang['import_error_fields'], count($raw));
1093                    $this->import_failures[$line] = ['error' => $error, 'user' => $raw, 'orig' => $csv];
1094                    continue;
1095                }
1096                array_splice($raw, 1, 0, auth_pwgen());                          // splice in a generated password
1097                $clean = $this->cleanImportUser($raw, $error);
1098                if ($clean && $this->importUser($clean, $error)) {
1099                    $sent = $this->notifyUser($clean[0], $clean[1], false);
1100                    if (!$sent) {
1101                        msg(sprintf($this->lang['import_notify_fail'], $clean[0], $clean[3]), -1);
1102                    }
1103                    $import_success_count++;
1104                } else {
1105                    $import_fail_count++;
1106                    array_splice($raw, 1, 1);                                  // remove the spliced in password
1107                    $this->import_failures[$line] = ['error' => $error, 'user' => $raw, 'orig' => $csv];
1108                }
1109            }
1110            msg(
1111                sprintf(
1112                    $this->lang['import_success_count'],
1113                    ($import_success_count + $import_fail_count),
1114                    $import_success_count
1115                ),
1116                ($import_success_count ? 1 : -1)
1117            );
1118            if ($import_fail_count) {
1119                msg(sprintf($this->lang['import_failure_count'], $import_fail_count), -1);
1120            }
1121        } else {
1122            msg($this->lang['import_error_readfail'], -1);
1123        }
1124
1125        // save import failures into the session
1126        if (!headers_sent()) {
1127            session_start();
1128            $_SESSION['import_failures'] = $this->import_failures;
1129            session_write_close();
1130        }
1131        return true;
1132    }
1133
1134    /**
1135     * Returns cleaned user data
1136     *
1137     * @param array $candidate raw values of line from input file
1138     * @param string $error
1139     * @return array|false cleaned data or false
1140     */
1141    protected function cleanImportUser($candidate, &$error)
1142    {
1143        global $INPUT;
1144
1145        // FIXME kludgy ....
1146        $INPUT->set('userid', $candidate[0]);
1147        $INPUT->set('userpass', $candidate[1]);
1148        $INPUT->set('username', $candidate[2]);
1149        $INPUT->set('usermail', $candidate[3]);
1150        $INPUT->set('usergroups', $candidate[4]);
1151
1152        $cleaned = $this->retrieveUser();
1153        [$user, /* pass */, $name, $mail, /* grps */] = $cleaned;
1154        if (empty($user)) {
1155            $error = $this->lang['import_error_baduserid'];
1156            return false;
1157        }
1158
1159        // no need to check password, handled elsewhere
1160
1161        if (!($this->auth->canDo('modName') xor empty($name))) {
1162            $error = $this->lang['import_error_badname'];
1163            return false;
1164        }
1165
1166        if ($this->auth->canDo('modMail')) {
1167            if (empty($mail) || !mail_isvalid($mail)) {
1168                $error = $this->lang['import_error_badmail'];
1169                return false;
1170            }
1171        } elseif (!empty($mail)) {
1172            $error = $this->lang['import_error_badmail'];
1173            return false;
1174        }
1175
1176        return $cleaned;
1177    }
1178
1179    /**
1180     * Adds imported user to auth backend
1181     *
1182     * Required a check of canDo('addUser') before
1183     *
1184     * @param array $user data of user
1185     * @param string &$error reference catched error message
1186     * @return bool whether successful
1187     */
1188    protected function importUser($user, &$error)
1189    {
1190        if (!$this->auth->triggerUserMod('create', $user)) {
1191            $error = $this->lang['import_error_create'];
1192            return false;
1193        }
1194
1195        return true;
1196    }
1197
1198    /**
1199     * Downloads failures as csv file
1200     */
1201    protected function downloadImportFailures()
1202    {
1203
1204        // ==============================================================================================
1205        // GENERATE OUTPUT
1206        // normal headers for downloading...
1207        header('Content-type: text/csv;charset=utf-8');
1208        header('Content-Disposition: attachment; filename="importfails.csv"');
1209#       // for debugging assistance, send as text plain to the browser
1210#       header('Content-type: text/plain;charset=utf-8');
1211
1212        // output the csv
1213        $fd = fopen('php://output', 'w');
1214        foreach ($this->import_failures as $fail) {
1215            fwrite($fd, $fail['orig']);
1216        }
1217        fclose($fd);
1218        die;
1219    }
1220
1221    /**
1222     * wrapper for is_uploaded_file to facilitate overriding by test suite
1223     *
1224     * @param string $file filename
1225     * @return bool
1226     */
1227    protected function isUploadedFile($file)
1228    {
1229        return is_uploaded_file($file);
1230    }
1231}
1232