1<?php
2
3use dokuwiki\Form\Form;
4
5/**
6 * Admin plugin based on usermanager with additional features:
7 * - importing passwords
8 * - setting defaults for empty values
9 */
10class admin_plugin_userimportextended extends DokuWiki_Admin_Plugin
11{
12    const DEFAULT_EMPTY = ['groups'];
13
14    /** @var auth_plugin_authplain */
15    protected $_auth;
16    /** @var array  */
17    protected $_import_failures = [];
18    /** @var array */
19    protected $defaults = ['email', 'name', 'password', 'groups'];
20
21    /**
22     * Constructor
23     */
24    public function __construct()
25    {
26        /** @var auth_plugin_authplain $auth */
27        global $auth;
28
29        if (!$auth instanceof auth_plugin_authplain) {
30            msg($this->getLang('error_badauth'));
31            return;
32        }
33
34        $this->_auth = $auth;
35
36        // attempt to retrieve any import failures from the session
37        if (!empty($_SESSION['import_failures'])){
38            $this->_import_failures = $_SESSION['import_failures'];
39        }
40    }
41
42    /**
43     * handle user request
44     */
45    public function handle()
46    {
47        global $INPUT;
48        $cmd = $INPUT->param('cmd');
49        if (!empty($cmd)) {
50            switch(key($cmd)) {
51                case "import":
52                    if (!checkSecurityToken()) return false;
53                    if (!$this->_auth->canDo('addUser')) return false;
54
55                    if ($this->validateDefaults() === true) {
56                        $this->_import();
57                    }
58                    break;
59                case "importfails":
60                    $this->_downloadImportFailures();
61                    break;
62            }
63        }
64        return true;
65    }
66
67    /**
68     * Output html of the admin page
69     */
70    public function html()
71    {
72        print $this->locale_xhtml('intro');
73        $this->printFormHTML();
74        $this->printFailuresHTML();
75    }
76
77    /**
78     * Prints the import form
79     */
80    protected function printFormHTML()
81    {
82        $form = new Form(['enctype' => 'multipart/form-data', 'id' => 'plugin__userimportextended_csv']);
83        $form->setHiddenField('do', 'admin');
84        $form->setHiddenField('page', $this->getPluginName());
85        $form->addFieldsetOpen($this->getLang('legend_defaults'));
86        $form->addTextInput('defaults[name]', $this->getLang('form_name') . '*');
87        $form->addHTML('<br>');
88        $form->addTextInput('defaults[email]', $this->getLang('form_email') . '*');
89        $form->addHTML('<br>');
90        $form->addTextInput('defaults[password]', $this->getLang('form_password') . '*');
91        $form->addHTML('<br>');
92        $form->addTextInput('defaults[groups]', $this->getLang('form_groups'));
93        $form->addFieldsetClose();
94        $form->addFieldsetOpen($this->getLang('legend_csv'));
95        $form->addElement(new \dokuwiki\Form\InputElement('file', 'import'))->attr('accept', '.csv');
96        $form->addHTML('<br>');
97        $form->addButton('cmd[import]', $this->getLang('btn_import'));
98        $form->addFieldsetClose();
99        echo $form->toHTML();
100    }
101
102    /**
103     * Prints a table of failed imports
104     */
105    protected function printFailuresHTML()
106    {
107        var_dump($this->lang);
108
109        global $ID;
110        $failure_download_link = wl($ID,array('do'=>'admin','page'=>'userimportextended','cmd[importfails]'=>1));
111
112        if ($this->_import_failures) {
113            $digits = strlen(count($this->_import_failures));
114            ptln('<div class="level3 import_failures">');
115            ptln('  <h3>'.$this->getLang('import_header').'</h3>');
116            ptln('  <table class="import_failures">');
117            ptln('    <thead>');
118            ptln('      <tr>');
119            ptln('        <th class="line">'.$this->getLang('line').'</th>');
120            ptln('        <th class="error">'.$this->getLang('error').'</th>');
121            ptln('        <th class="userid">'.$this->getLang('user_id').'</th>');
122            ptln('        <th class="userpass">'.$this->getLang('user_pass').'</th>');
123            ptln('        <th class="username">'.$this->getLang('user_name').'</th>');
124            ptln('        <th class="usermail">'.$this->getLang('user_mail').'</th>');
125            ptln('        <th class="usergroups">'.$this->getLang('user_groups').'</th>');
126            ptln('      </tr>');
127            ptln('    </thead>');
128            ptln('    <tbody>');
129            foreach ($this->_import_failures as $line => $failure) {
130                ptln('      <tr>');
131                ptln('        <td class="lineno"> '.sprintf('%0'.$digits.'d',$line).' </td>');
132                ptln('        <td class="error">' .$failure['error'].' </td>');
133                ptln('        <td class="field userid"> '.hsc($failure['user'][0]).' </td>');
134                ptln('        <td class="field userpass"> '.hsc($failure['user'][1]).' </td>');
135                ptln('        <td class="field username"> '.hsc($failure['user'][2]).' </td>');
136                ptln('        <td class="field usermail"> '.hsc($failure['user'][3]).' </td>');
137                ptln('        <td class="field usergroups"> '.hsc($failure['user'][4]).' </td>');
138                ptln('      </tr>');
139            }
140            ptln('    </tbody>');
141            ptln('  </table>');
142            ptln('  <p><a href="'.$failure_download_link.'">'.$this->getLang('import_downloadfailures').'</a></p>');
143            ptln('</div>');
144        }
145    }
146
147    /**
148     * Tries to set all defaults. Returns false if any of the required defaults are empty.
149     *
150     * @return bool
151     */
152    protected function validateDefaults()
153    {
154        foreach ($this->defaults as $field) {
155            if (!in_array($field, self::DEFAULT_EMPTY) && empty($_REQUEST['defaults'][$field])) {
156                msg($this->getLang('error_required_defaults'), -1);
157                return false;
158            }
159            $this->defaults[$field] = $_REQUEST['defaults'][$field];
160
161            // make sure groups include "user"
162            if ($field === 'groups' && strpos($_REQUEST['defaults'][$field], 'user') === false) {
163                $this->defaults[$field] .= ',user';
164            }
165        }
166        return true;
167    }
168
169    /**
170     * Import a file of users in csv format
171     *
172     * csv file should have 5 columns, user_id, password, full name, email, groups (comma separated)
173     *
174     * @return bool whether successful
175     */
176    protected function _import() {
177        // check we are allowed to add users
178        if (!checkSecurityToken()) return false;
179        if (!$this->_auth->canDo('addUser')) return false;
180
181        // check file uploaded ok.
182        $upl = $this->_isUploadedFile($_FILES['import']['tmp_name']);
183        if (empty($_FILES['import']['size']) || !empty($_FILES['import']['error']) && $upl) {
184             msg($this->getLang('import_error_upload'),-1);
185            return false;
186        }
187        // retrieve users from the file
188        $this->_import_failures = array();
189        $import_success_count = 0;
190        $import_fail_count = 0;
191        $line = 0;
192        $fd = fopen($_FILES['import']['tmp_name'],'r');
193        if ($fd) {
194            while($csv = fgets($fd)){
195                if (!utf8_check($csv)) {
196                    $csv = utf8_encode($csv);
197                }
198                $raw = str_getcsv($csv);
199                $error = '';                        // clean out any errors from the previous line
200                // data checks...
201                if (1 == ++$line) {
202                    if ($raw[0] == 'user_id' || $raw[0] == $this->getLang('user_id')) continue;    // skip headers
203                }
204                // in contrast to User Manager, 5 columns are required
205                if (count($raw) < 5) {                                        // need at least five fields
206                    $import_fail_count++;
207                    $error = sprintf($this->getLang('import_error_fields'), count($raw));
208                    $this->_import_failures[$line] = array('error' => $error, 'user' => $raw, 'orig' => $csv);
209                    continue;
210                }
211
212                $clean = $this->_cleanImportUser($raw, $error);
213                if ($clean && $this->_addImportUser($clean, $error)) {
214                    $sent = $this->_notifyUser($clean[0],$clean[1],false);
215                    if (!$sent){
216                         msg(sprintf($this->getLang('import_notify_fail'),$clean[0],$clean[3]),-1);
217                    }
218                    $import_success_count++;
219                } else {
220                    $import_fail_count++;
221                    $this->_import_failures[$line] = array('error' => $error, 'user' => $raw, 'orig' => $csv);
222                }
223            }
224             msg(sprintf($this->getLang('import_success_count'), ($import_success_count+$import_fail_count), $import_success_count),($import_success_count ? 1 : -1));
225            if ($import_fail_count) {
226                 msg(sprintf($this->getLang('import_failure_count'), $import_fail_count),-1);
227            }
228        } else {
229             msg($this->getLang('import_error_readfail'),-1);
230        }
231
232        // save import failures into the session
233        if (!headers_sent()) {
234            session_start();
235            $_SESSION['import_failures'] = $this->_import_failures;
236            session_write_close();
237        }
238        return true;
239    }
240
241    /**
242     * Replaces empty values with defaults
243     *
244     * @param array $candidate
245     */
246    protected function insertDefaults(&$candidate)
247    {
248        if (empty($candidate[1])) {
249            $candidate[1] = $this->defaults['password'];
250        }
251        if (empty($candidate[2])) {
252            $candidate[2] = $this->defaults['name'];
253        }
254        if (empty($candidate[3])) {
255            $candidate[3] = $this->defaults['email'];
256        }
257        if (empty($candidate[4])) {
258            $candidate[4] = $this->defaults['groups'];
259        }
260    }
261
262    /**
263     * Returns cleaned user data
264     *
265     * @param array $candidate raw values of line from input file
266     * @param string $error
267     * @return array|false cleaned data or false
268     */
269    protected function _cleanImportUser($candidate, &$error) {
270        global $INPUT;
271
272        // fill in defaults if needed
273        $this->insertDefaults($candidate);
274
275        // kludgy ....
276        $INPUT->set('userid', $candidate[0]);
277        $INPUT->set('userpass', $candidate[1]);
278        $INPUT->set('username', $candidate[2]);
279        $INPUT->set('usermail', $candidate[3]);
280        $INPUT->set('usergroups', $candidate[4]);
281
282        $cleaned = $this->_retrieveUser();
283        list($user,/* $pass */,$name,$mail,/* $grps */) = $cleaned;
284        if (empty($user)) {
285            $error = $this->getLang('import_error_baduserid');
286            return false;
287        }
288
289        // no need to check password, handled elsewhere
290
291        if (!($this->_auth->canDo('modName') xor empty($name))){
292            $error = $this->getLang('import_error_badname');
293            return false;
294        }
295
296        if ($this->_auth->canDo('modMail')) {
297            if (empty($mail) || !mail_isvalid($mail)) {
298                $error = $this->getLang('import_error_badmail');
299                return false;
300            }
301        } else {
302            if (!empty($mail)) {
303                $error = $this->getLang('import_error_badmail');
304                return false;
305            }
306        }
307
308        return $cleaned;
309    }
310
311    /**
312     * Adds imported user to auth backend
313     *
314     * Required a check of canDo('addUser') before
315     *
316     * @param array  $user   data of user
317     * @param string &$error reference catched error message
318     * @return bool whether successful
319     */
320    protected function _addImportUser($user, & $error){
321        if (!$this->_auth->triggerUserMod('create', $user)) {
322            $error = $this->getLang('import_error_create');
323            return false;
324        }
325
326        return true;
327    }
328
329    /**
330     * Retrieve & clean user data from the form
331     *
332     * @param bool $clean whether the cleanUser method of the authentication backend is applied
333     * @return array (user, password, full name, email, array(groups))
334     */
335    protected function _retrieveUser($clean=true) {
336        /** @var DokuWiki_Auth_Plugin $auth */
337        global $auth;
338        global $INPUT;
339
340        $user = [];
341        $user[0] = ($clean) ? $auth->cleanUser($INPUT->str('userid')) : $INPUT->str('userid');
342        $user[1] = $INPUT->str('userpass');
343        $user[2] = $INPUT->str('username');
344        $user[3] = $INPUT->str('usermail');
345        $user[4] = explode(',',$INPUT->str('usergroups'));
346        $user[5] = $INPUT->str('userpass2');                // repeated password for confirmation
347
348        $user[4] = array_map('trim',$user[4]);
349        if($clean) $user[4] = array_map(array($auth,'cleanGroup'),$user[4]);
350        $user[4] = array_filter($user[4]);
351        $user[4] = array_unique($user[4]);
352        if(!count($user[4])) $user[4] = null;
353
354        return $user;
355    }
356
357    /**
358     * Send password change notification email
359     *
360     * @param string $user         id of user
361     * @param string $password     plain text
362     * @param bool   $status_alert whether status alert should be shown
363     * @return bool whether succesful
364     */
365    protected function _notifyUser($user, $password, $status_alert=true) {
366        $sent = auth_sendPassword($user,$password);
367        if ($sent) {
368            if ($status_alert) {
369                 msg($this->getLang('notify_ok'), 1);
370            }
371        } else {
372            if ($status_alert) {
373                 msg($this->getLang('notify_fail'), -1);
374            }
375        }
376
377        return $sent;
378    }
379
380    /**
381     * Downloads failures as csv file
382     */
383    protected function _downloadImportFailures(){
384
385        // ==============================================================================================
386        // GENERATE OUTPUT
387        // normal headers for downloading...
388        header('Content-type: text/csv;charset=utf-8');
389        header('Content-Disposition: attachment; filename="importfails.csv"');
390#       // for debugging assistance, send as text plain to the browser
391#       header('Content-type: text/plain;charset=utf-8');
392
393        // output the csv
394        $fd = fopen('php://output','w');
395        foreach ($this->_import_failures as $fail) {
396            fputs($fd, $fail['orig']);
397        }
398        fclose($fd);
399        die;
400    }
401
402    /**
403     * wrapper for is_uploaded_file to facilitate overriding by test suite
404     *
405     * @param string $file filename
406     * @return bool
407     */
408    protected function _isUploadedFile($file) {
409        return is_uploaded_file($file);
410    }
411}
412