1 <?php
2 
3 use dokuwiki\Extension\AdminPlugin;
4 use dokuwiki\Extension\AuthPlugin;
5 use dokuwiki\Utf8\Clean;
6 use 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  */
24 class 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