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