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